Compare commits
No commits in common. "52f4b7616e119e6bdf203c00fe64619d38150d00" and "d533a8111b57ab0d7839b2f3129d8b4c9e200aa3" have entirely different histories.
52f4b7616e
...
d533a8111b
@ -1,457 +0,0 @@
|
|||||||
# YARP Gateway 架构文档
|
|
||||||
|
|
||||||
## 1. 整体架构模式
|
|
||||||
|
|
||||||
本项目基于 **YARP (Yet Another Reverse Proxy)** 实现的 API 网关,采用 **反向代理模式**,支持多租户路由、动态配置和分布式负载均衡。
|
|
||||||
|
|
||||||
### 1.1 架构图
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 外部请求 │
|
|
||||||
└─────────────────────────────────┬───────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ASP.NET Core Pipeline │
|
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐ │
|
|
||||||
│ │ CORS 中间件 │ -> │ JwtTransformMiddleware │ -> │ TenantRoutingMiddleware │ │
|
|
||||||
│ └────────────────┘ └─────────────────────┘ └──────────────────────┘ │
|
|
||||||
└─────────────────────────────────┬───────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ YARP Reverse Proxy │
|
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌───────────────────────────┐ ┌──────────────────────────────────────┐ │
|
|
||||||
│ │ DynamicProxyConfigProvider │ -> │ DistributedWeightedRoundRobinPolicy │ │
|
|
||||||
│ └───────────┬───────────────┘ └──────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ▼ │
|
|
||||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ RouteConfig / ClusterConfig │ │
|
|
||||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────┬───────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 后端服务集群 │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ Service A│ │ Service B│ │ Service C│ │ Service D│ │
|
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 核心设计模式
|
|
||||||
|
|
||||||
| 模式 | 应用场景 | 实现位置 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| 反向代理 | 请求转发 | `Yarp.ReverseProxy` |
|
|
||||||
| 策略模式 | 负载均衡策略 | `DistributedWeightedRoundRobinPolicy` |
|
|
||||||
| 观察者模式 | 配置变更监听 | `PgSqlConfigChangeListener` |
|
|
||||||
| 工厂模式 | DbContext 创建 | `GatewayDbContextFactory` |
|
|
||||||
| 单例模式 | 配置提供者 | `DatabaseRouteConfigProvider`, `DatabaseClusterConfigProvider` |
|
|
||||||
| 生产者-消费者 | 配置变更通知 | `Channel<bool>` in `PgSqlConfigChangeListener` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 核心组件和职责
|
|
||||||
|
|
||||||
### 2.1 中间件层 (Middleware)
|
|
||||||
|
|
||||||
#### JwtTransformMiddleware
|
|
||||||
**文件路径**: `src/Middleware/JwtTransformMiddleware.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 解析 JWT Token
|
|
||||||
- 提取租户信息 (tenant claim)
|
|
||||||
- 将用户信息注入请求头
|
|
||||||
|
|
||||||
**处理流程**:
|
|
||||||
```
|
|
||||||
Authorization Header -> JWT 解析 -> 提取 Claims -> 注入 X-Tenant-Id, X-User-Id, X-User-Name, X-Roles
|
|
||||||
```
|
|
||||||
|
|
||||||
#### TenantRoutingMiddleware
|
|
||||||
**文件路径**: `src/Middleware/TenantRoutingMiddleware.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 从请求头获取租户 ID
|
|
||||||
- 根据 URL 路径提取服务名称
|
|
||||||
- 查询路由缓存获取目标集群
|
|
||||||
- 设置动态集群 ID
|
|
||||||
|
|
||||||
### 2.2 配置提供层 (Config Providers)
|
|
||||||
|
|
||||||
#### DynamicProxyConfigProvider
|
|
||||||
**文件路径**: `src/DynamicProxy/DynamicProxyConfigProvider.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 实现 YARP 的 `IProxyConfigProvider` 接口
|
|
||||||
- 整合路由和集群配置
|
|
||||||
- 提供配置变更通知机制
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public interface IProxyConfigProvider
|
|
||||||
{
|
|
||||||
IProxyConfig GetConfig();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DatabaseRouteConfigProvider
|
|
||||||
**文件路径**: `src/Config/DatabaseRouteConfigProvider.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 从数据库加载路由配置
|
|
||||||
- 转换为 YARP `RouteConfig` 格式
|
|
||||||
- 支持热重载
|
|
||||||
|
|
||||||
#### DatabaseClusterConfigProvider
|
|
||||||
**文件路径**: `src/Config/DatabaseClusterConfigProvider.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 从数据库加载集群配置
|
|
||||||
- 管理服务实例 (地址、权重)
|
|
||||||
- 配置健康检查策略
|
|
||||||
|
|
||||||
### 2.3 服务层 (Services)
|
|
||||||
|
|
||||||
#### RouteCache
|
|
||||||
**文件路径**: `src/Services/RouteCache.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 内存缓存路由信息
|
|
||||||
- 支持全局路由和租户专用路由
|
|
||||||
- 提供快速查询接口
|
|
||||||
|
|
||||||
**数据结构**:
|
|
||||||
```
|
|
||||||
_globalRoutes: ConcurrentDictionary<string, RouteInfo> // 全局路由
|
|
||||||
_tenantRoutes: ConcurrentDictionary<string, ConcurrentDictionary<string, RouteInfo>> // 租户路由
|
|
||||||
```
|
|
||||||
|
|
||||||
**查询优先级**: 租户专用路由 > 全局路由
|
|
||||||
|
|
||||||
#### PgSqlConfigChangeListener
|
|
||||||
**文件路径**: `src/Services/PgSqlConfigChangeListener.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 监听 PostgreSQL NOTIFY 事件
|
|
||||||
- 双重保障:事件监听 + 轮询回退
|
|
||||||
- 触发配置热重载
|
|
||||||
|
|
||||||
**监听流程**:
|
|
||||||
```
|
|
||||||
PostgreSQL NOTIFY -> OnNotification -> _reloadChannel -> ReloadConfigAsync
|
|
||||||
│
|
|
||||||
└── FallbackPollingAsync (5分钟轮询)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### KubernetesPendingSyncService
|
|
||||||
**文件路径**: `src/Services/KubernetesPendingSyncService.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 同步 Kubernetes 服务发现
|
|
||||||
- 管理待处理服务列表
|
|
||||||
- 清理过期服务记录
|
|
||||||
|
|
||||||
#### RedisConnectionManager
|
|
||||||
**文件路径**: `src/Services/RedisConnectionManager.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 管理 Redis 连接
|
|
||||||
- 提供分布式锁实现
|
|
||||||
- 连接池管理
|
|
||||||
|
|
||||||
### 2.4 负载均衡层
|
|
||||||
|
|
||||||
#### DistributedWeightedRoundRobinPolicy
|
|
||||||
**文件路径**: `src/LoadBalancing/DistributedWeightedRoundRobinPolicy.cs`
|
|
||||||
|
|
||||||
**职责**:
|
|
||||||
- 实现加权轮询负载均衡
|
|
||||||
- 基于 Redis 的分布式状态存储
|
|
||||||
- 支持实例权重配置
|
|
||||||
|
|
||||||
**算法流程**:
|
|
||||||
```
|
|
||||||
1. 获取分布式锁 (Redis)
|
|
||||||
2. 读取负载均衡状态
|
|
||||||
3. 计算权重选择目标
|
|
||||||
4. 更新状态并释放锁
|
|
||||||
5. 失败时降级到简单选择
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 数据流和请求处理流程
|
|
||||||
|
|
||||||
### 3.1 请求处理流程图
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Client as 客户端
|
|
||||||
participant CORS as CORS中间件
|
|
||||||
participant JWT as JwtTransformMiddleware
|
|
||||||
participant Tenant as TenantRoutingMiddleware
|
|
||||||
participant YARP as YARP代理
|
|
||||||
participant LB as 负载均衡器
|
|
||||||
participant Service as 后端服务
|
|
||||||
|
|
||||||
Client->>CORS: HTTP请求
|
|
||||||
CORS->>JWT: 跨域检查通过
|
|
||||||
JWT->>JWT: 解析JWT Token
|
|
||||||
JWT->>Tenant: 注入租户信息头
|
|
||||||
Tenant->>Tenant: 提取服务名称
|
|
||||||
Tenant->>Tenant: 查询RouteCache
|
|
||||||
Tenant->>YARP: 设置动态集群ID
|
|
||||||
YARP->>LB: 获取可用目标
|
|
||||||
LB->>LB: 加权轮询选择
|
|
||||||
LB->>Service: 转发请求
|
|
||||||
Service-->>Client: 返回响应
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 配置变更流程
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[数据库变更] --> B[SaveChangesAsync]
|
|
||||||
B --> C[DetectConfigChanges]
|
|
||||||
C --> D[NOTIFY gateway_config_changed]
|
|
||||||
D --> E[PgSqlConfigChangeListener]
|
|
||||||
E --> F{收到通知?}
|
|
||||||
F -->|是| G[ReloadConfigAsync]
|
|
||||||
F -->|否| H[轮询检测版本变化]
|
|
||||||
H --> G
|
|
||||||
G --> I[RouteCache.ReloadAsync]
|
|
||||||
G --> J[DatabaseRouteConfigProvider.ReloadAsync]
|
|
||||||
G --> K[DatabaseClusterConfigProvider.ReloadAsync]
|
|
||||||
I --> L[更新内存缓存]
|
|
||||||
J --> L
|
|
||||||
K --> L
|
|
||||||
L --> M[DynamicProxyConfigProvider.UpdateConfig]
|
|
||||||
M --> N[触发 IChangeToken]
|
|
||||||
N --> O[YARP重新加载配置]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Kubernetes 服务发现流程
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Kubernetes API │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ 30s 间隔
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────┐
|
|
||||||
│ KubernetesPendingSyncService │
|
|
||||||
├─────────────────────────────┤
|
|
||||||
│ 1. 获取 K8s 服务列表 │
|
|
||||||
│ 2. 对比现有待处理记录 │
|
|
||||||
│ 3. 新增/更新/清理记录 │
|
|
||||||
└────────┬────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────┐
|
|
||||||
│ GwPendingServiceDiscovery │
|
|
||||||
│ (待处理服务发现表) │
|
|
||||||
└────────┬────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────┐
|
|
||||||
│ PendingServicesController │
|
|
||||||
│ - GET: 查看待处理服务 │
|
|
||||||
│ - POST /assign: 分配集群 │
|
|
||||||
│ - POST /reject: 拒绝服务 │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 关键抽象层
|
|
||||||
|
|
||||||
### 4.1 配置模型
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ 配置层次结构 │
|
|
||||||
├───────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
|
||||||
│ │ JwtConfig │ │ RedisConfig │ │
|
|
||||||
│ │ - Authority │ │ - Connection │ │
|
|
||||||
│ │ - Audience │ │ - Database │ │
|
|
||||||
│ │ - Validate* │ │ - InstanceName │ │
|
|
||||||
│ └─────────────────┘ └─────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ DynamicProxyConfigProvider │ │
|
|
||||||
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
|
|
||||||
│ │ │ RouteConfig[] │ │ ClusterConfig[] │ │ │
|
|
||||||
│ │ │ - RouteId │ │ - ClusterId │ │ │
|
|
||||||
│ │ │ - ClusterId │ │ - Destinations │ │ │
|
|
||||||
│ │ │ - Match.Path │ │ - LoadBalancing │ │ │
|
|
||||||
│ │ │ - Metadata │ │ - HealthCheck │ │ │
|
|
||||||
│ │ └─────────────────┘ └─────────────────┘ │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 数据模型
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌─────────────────┐
|
|
||||||
│ GwTenant │ │ GwTenantRoute │
|
|
||||||
├─────────────────┤ ├─────────────────┤
|
|
||||||
│ Id │ │ Id │
|
|
||||||
│ TenantCode ────┼────►│ TenantCode │
|
|
||||||
│ TenantName │ │ ServiceName │
|
|
||||||
│ Status │ │ ClusterId │
|
|
||||||
│ Version │ │ PathPattern │
|
|
||||||
│ IsDeleted │ │ Priority │
|
|
||||||
└─────────────────┘ │ IsGlobal │
|
|
||||||
│ Status │
|
|
||||||
│ Version │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ GwServiceInstance│
|
|
||||||
├─────────────────┤
|
|
||||||
│ Id │
|
|
||||||
│ ClusterId ────┤
|
|
||||||
│ DestinationId │
|
|
||||||
│ Address │
|
|
||||||
│ Health │
|
|
||||||
│ Weight │
|
|
||||||
│ Status │
|
|
||||||
│ Version │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 接口定义
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 路由缓存接口
|
|
||||||
public interface IRouteCache
|
|
||||||
{
|
|
||||||
Task InitializeAsync();
|
|
||||||
Task ReloadAsync();
|
|
||||||
RouteInfo? GetRoute(string tenantCode, string serviceName);
|
|
||||||
RouteInfo? GetRouteByPath(string path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redis 连接管理接口
|
|
||||||
public interface IRedisConnectionManager
|
|
||||||
{
|
|
||||||
IConnectionMultiplexer GetConnection();
|
|
||||||
Task<IDisposable> AcquireLockAsync(string key, TimeSpan? expiry = null);
|
|
||||||
Task<T> ExecuteInLockAsync<T>(string key, Func<Task<T>> func, TimeSpan? expiry = null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 负载均衡策略接口 (YARP)
|
|
||||||
public interface ILoadBalancingPolicy
|
|
||||||
{
|
|
||||||
string Name { get; }
|
|
||||||
DestinationState? PickDestination(HttpContext context, ClusterState cluster, IReadOnlyList<DestinationState> availableDestinations);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 入口点分析
|
|
||||||
|
|
||||||
### 5.1 程序入口 (`Program.cs`)
|
|
||||||
|
|
||||||
**文件路径**: `src/Program.cs`
|
|
||||||
|
|
||||||
**启动流程**:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 创建 WebApplication Builder
|
|
||||||
└── 配置 Serilog 日志
|
|
||||||
|
|
||||||
2. 配置选项
|
|
||||||
├── JwtConfig (JWT 认证配置)
|
|
||||||
└── RedisConfig (Redis 连接配置)
|
|
||||||
|
|
||||||
3. 注册数据库服务
|
|
||||||
└── GatewayDbContext (PostgreSQL)
|
|
||||||
|
|
||||||
4. 注册核心服务 (Singleton)
|
|
||||||
├── DatabaseRouteConfigProvider
|
|
||||||
├── DatabaseClusterConfigProvider
|
|
||||||
├── RouteCache
|
|
||||||
├── RedisConnectionManager
|
|
||||||
├── DynamicProxyConfigProvider
|
|
||||||
└── DistributedWeightedRoundRobinPolicy
|
|
||||||
|
|
||||||
5. 注册后台服务 (HostedService)
|
|
||||||
├── PgSqlConfigChangeListener
|
|
||||||
└── KubernetesPendingSyncService
|
|
||||||
|
|
||||||
6. 配置中间件管道
|
|
||||||
├── CORS
|
|
||||||
├── JwtTransformMiddleware
|
|
||||||
└── TenantRoutingMiddleware
|
|
||||||
|
|
||||||
7. 映射端点
|
|
||||||
├── /health (健康检查)
|
|
||||||
├── /api/gateway/* (管理 API)
|
|
||||||
└── /api/* (代理路由)
|
|
||||||
|
|
||||||
8. 初始化并运行
|
|
||||||
└── RouteCache.InitializeAsync()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 依赖注入关系
|
|
||||||
|
|
||||||
```
|
|
||||||
Program.cs
|
|
||||||
│
|
|
||||||
├── Config/
|
|
||||||
│ ├── JwtConfig (Options)
|
|
||||||
│ ├── RedisConfig (Options + Singleton)
|
|
||||||
│ ├── DatabaseRouteConfigProvider (Singleton)
|
|
||||||
│ └── DatabaseClusterConfigProvider (Singleton)
|
|
||||||
│
|
|
||||||
├── DynamicProxy/
|
|
||||||
│ └── DynamicProxyConfigProvider (Singleton, IProxyConfigProvider)
|
|
||||||
│
|
|
||||||
├── Services/
|
|
||||||
│ ├── RouteCache (Singleton, IRouteCache)
|
|
||||||
│ ├── RedisConnectionManager (Singleton)
|
|
||||||
│ ├── PgSqlConfigChangeListener (HostedService)
|
|
||||||
│ └── KubernetesPendingSyncService (HostedService)
|
|
||||||
│
|
|
||||||
├── LoadBalancing/
|
|
||||||
│ └── DistributedWeightedRoundRobinPolicy (Singleton, ILoadBalancingPolicy)
|
|
||||||
│
|
|
||||||
└── Data/
|
|
||||||
└── GatewayDbContext (DbContextFactory)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 技术栈
|
|
||||||
|
|
||||||
| 组件 | 技术 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| 反向代理 | YARP 2.x | 核心代理功能 |
|
|
||||||
| 数据库 | PostgreSQL + EF Core | 配置存储 |
|
|
||||||
| 缓存 | Redis | 分布式状态、锁 |
|
|
||||||
| 服务发现 | Fengling.ServiceDiscovery | Kubernetes 集成 |
|
|
||||||
| 日志 | Serilog | 结构化日志 |
|
|
||||||
| 容器化 | Docker | 部署支持 |
|
|
||||||
| 目标框架 | .NET 10.0 | 运行时 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 扩展点
|
|
||||||
|
|
||||||
1. **负载均衡策略**: 实现 `ILoadBalancingPolicy` 接口
|
|
||||||
2. **配置提供者**: 继承 `IProxyConfigProvider`
|
|
||||||
3. **中间件**: 添加自定义中间件到管道
|
|
||||||
4. **服务发现**: 扩展 `IServiceDiscoveryProvider`
|
|
||||||
5. **健康检查**: 配置 `HealthCheckConfig`
|
|
||||||
@ -1,499 +0,0 @@
|
|||||||
# YARP 网关项目技术债务与关注点分析
|
|
||||||
|
|
||||||
> 分析日期:2026-02-28
|
|
||||||
> 分析范围:核心代码、配置、数据访问层
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、严重安全问题 🔴
|
|
||||||
|
|
||||||
### 1.1 硬编码凭据泄露
|
|
||||||
|
|
||||||
**文件位置:** `src/Config/RedisConfig.cs:5`
|
|
||||||
```csharp
|
|
||||||
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** Redis 连接字符串包含明文密码,直接硬编码在源代码中。此代码提交到版本控制系统后,密码将永久暴露。
|
|
||||||
|
|
||||||
**影响范围:**
|
|
||||||
- 攻击者获取代码后可直接访问 Redis 服务
|
|
||||||
- 违反安全合规要求(如等保、GDPR)
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
// 使用环境变量或密钥管理服务
|
|
||||||
public string ConnectionString { get; set; } =
|
|
||||||
Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") ?? string.Empty;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.2 配置文件凭据泄露
|
|
||||||
|
|
||||||
**文件位置:** `src/appsettings.json:19,28`
|
|
||||||
```json
|
|
||||||
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
|
|
||||||
"ConnectionString": "81.68.223.70:6379"
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 数据库连接字符串和 Redis 配置包含明文凭据,且这些配置文件通常会被提交到 Git 仓库。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 使用 `appsettings.Development.json` 存储开发环境配置,并加入 `.gitignore`
|
|
||||||
- 生产环境使用环境变量或 Azure Key Vault / AWS Secrets Manager
|
|
||||||
- 敏感配置使用 `dotnet user-secrets` 管理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.3 JWT 令牌未验证
|
|
||||||
|
|
||||||
**文件位置:** `src/Middleware/JwtTransformMiddleware.cs:39-40`
|
|
||||||
```csharp
|
|
||||||
var jwtHandler = new JwtSecurityTokenHandler();
|
|
||||||
var jwtToken = jwtHandler.ReadJwtToken(token);
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 中间件仅**读取**JWT令牌,未进行签名验证、过期检查或颁发者验证。攻击者可伪造任意JWT令牌。
|
|
||||||
|
|
||||||
**影响范围:**
|
|
||||||
- 任何人可伪造租户ID、用户ID、角色信息
|
|
||||||
- 可冒充任意用户访问系统
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
// 应使用标准的 JWT 验证流程
|
|
||||||
var validationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = _jwtConfig.Authority,
|
|
||||||
ValidAudience = _jwtConfig.Audience,
|
|
||||||
IssuerSigningKey = GetSigningKey() // 从配置获取公钥
|
|
||||||
};
|
|
||||||
|
|
||||||
var principal = jwtHandler.ValidateToken(token, validationParameters, out _);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.4 API 端点无认证保护
|
|
||||||
|
|
||||||
**文件位置:** `src/Controllers/GatewayConfigController.cs` 和 `src/Controllers/PendingServicesController.cs`
|
|
||||||
|
|
||||||
**问题描述:** 所有管理API端点均未添加 `[Authorize]` 特性,任何人可直接调用:
|
|
||||||
- `POST /api/gateway/tenants` - 创建租户
|
|
||||||
- `POST /api/gateway/routes` - 创建路由
|
|
||||||
- `POST /api/gateway/clusters/{clusterId}/instances` - 添加服务实例
|
|
||||||
- `POST /api/gateway/pending-services/{id}/assign` - 分配服务
|
|
||||||
|
|
||||||
**影响范围:**
|
|
||||||
- 攻击者可随意修改网关配置
|
|
||||||
- 可注入恶意服务地址进行流量劫持
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/gateway")]
|
|
||||||
[Authorize(Roles = "Admin")] // 添加认证要求
|
|
||||||
public class GatewayConfigController : ControllerBase
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.5 租户ID头部信任问题
|
|
||||||
|
|
||||||
**文件位置:** `src/Middleware/TenantRoutingMiddleware.cs:25`
|
|
||||||
```csharp
|
|
||||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 直接从请求头读取租户ID,未与JWT中的租户声明进行比对验证。攻击者可伪造 `X-Tenant-Id` 头部访问其他租户数据。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
// 从已验证的 JWT claims 中获取租户ID
|
|
||||||
var jwtTenantId = context.User.FindFirst("tenant")?.Value;
|
|
||||||
var headerTenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(jwtTenantId) && jwtTenantId != headerTenantId)
|
|
||||||
{
|
|
||||||
// 记录安全事件
|
|
||||||
_logger.LogWarning("Tenant ID mismatch: JWT={JwtTenant}, Header={HeaderTenant}",
|
|
||||||
jwtTenantId, headerTenantId);
|
|
||||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、技术债务 🟠
|
|
||||||
|
|
||||||
### 2.1 ID生成策略问题
|
|
||||||
|
|
||||||
**文件位置:** `src/Controllers/GatewayConfigController.cs:484-487`
|
|
||||||
```csharp
|
|
||||||
private long GenerateId()
|
|
||||||
{
|
|
||||||
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 使用时间戳毫秒生成ID,在高并发场景下可能产生重复ID。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 使用数据库自增主键(已有配置)
|
|
||||||
- 或使用雪花算法(Snowflake ID)
|
|
||||||
- 或使用 `Guid.NewGuid()`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 Redis连接重复初始化
|
|
||||||
|
|
||||||
**文件位置:**
|
|
||||||
- `src/Program.cs:39-60` - 注册 `IConnectionMultiplexer`
|
|
||||||
- `src/Services/RedisConnectionManager.cs:25-46` - 内部再次创建连接
|
|
||||||
|
|
||||||
**问题描述:** Redis连接被初始化两次,造成资源浪费和配置不一致风险。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
// Program.cs 中只注册一次
|
|
||||||
builder.Services.AddSingleton<IRedisConnectionManager, RedisConnectionManager>();
|
|
||||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
|
||||||
sp.GetRequiredService<IRedisConnectionManager>().GetConnection());
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 DTO 内嵌定义
|
|
||||||
|
|
||||||
**文件位置:** `src/Controllers/GatewayConfigController.cs:444-481`
|
|
||||||
|
|
||||||
**问题描述:** 多个DTO类定义在Controller内部,不利于复用和测试。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 将 DTO 移至 `src/DTOs/` 或 `src/Models/Dto/` 目录
|
|
||||||
- 使用 Auto Mapper 或 Mapster 进行对象映射
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.4 魔法数字
|
|
||||||
|
|
||||||
**文件位置:** 多处使用数字常量
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// RouteCache.cs:99
|
|
||||||
.Where(r => r.Status == 1 && !r.IsDeleted)
|
|
||||||
|
|
||||||
// GatewayConfigController.cs:239
|
|
||||||
route.Status = 1;
|
|
||||||
|
|
||||||
// KubernetesPendingSyncService.cs:13
|
|
||||||
private readonly TimeSpan _syncInterval = TimeSpan.FromSeconds(30);
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 状态值、超时时间等使用硬编码数字,降低代码可读性和可维护性。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
// 定义常量或枚举
|
|
||||||
public static class RouteStatus
|
|
||||||
{
|
|
||||||
public const int Active = 1;
|
|
||||||
public const int Inactive = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ServiceConstants
|
|
||||||
{
|
|
||||||
public static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromSeconds(30);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.5 异步方法命名不一致
|
|
||||||
|
|
||||||
**文件位置:** `src/Config/DatabaseRouteConfigProvider.cs:23`
|
|
||||||
```csharp
|
|
||||||
_ = LoadConfigAsync(); // Fire-and-forget without await
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 构造函数中调用异步方法但未等待完成,可能导致初始化竞态条件。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 使用工厂模式异步初始化
|
|
||||||
- 或在 `Program.cs` 中显式调用初始化方法
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、性能瓶颈风险 🟡
|
|
||||||
|
|
||||||
### 3.1 负载均衡锁竞争
|
|
||||||
|
|
||||||
**文件位置:** `src/LoadBalancing/DistributedWeightedRoundRobinPolicy.cs:48-53`
|
|
||||||
```csharp
|
|
||||||
var lockAcquired = db.StringSet(
|
|
||||||
lockKey,
|
|
||||||
lockValue,
|
|
||||||
TimeSpan.FromMilliseconds(500),
|
|
||||||
When.NotExists
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 每次请求都需要获取Redis分布式锁,高并发下会成为瓶颈。锁获取失败时降级策略不可靠。
|
|
||||||
|
|
||||||
**影响:**
|
|
||||||
- 单集群QPS受限
|
|
||||||
- Redis延迟增加时网关吞吐量下降
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 考虑使用本地缓存 + 定期同步策略
|
|
||||||
- 或使用一致性哈希算法避免锁需求
|
|
||||||
- 增加本地计数器作为快速路径
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 路由缓存全量加载
|
|
||||||
|
|
||||||
**文件位置:** `src/Services/RouteCache.cs:94-137`
|
|
||||||
```csharp
|
|
||||||
var routes = await db.TenantRoutes
|
|
||||||
.Where(r => r.Status == 1 && !r.IsDeleted)
|
|
||||||
.ToListAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 每次重载都清空并重新加载所有路由,大数据量下性能差。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 实现增量更新机制
|
|
||||||
- 使用版本号比对只更新变更项
|
|
||||||
- 添加分页加载支持
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 数据库查询未优化
|
|
||||||
|
|
||||||
**文件位置:** `src/Controllers/GatewayConfigController.cs:145-148`
|
|
||||||
```csharp
|
|
||||||
var currentRouteVersion = await db.TenantRoutes
|
|
||||||
.OrderByDescending(r => r.Version)
|
|
||||||
.Select(r => r.Version)
|
|
||||||
.FirstOrDefaultAsync(stoppingToken);
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 每次轮询都执行 `ORDER BY` 查询获取最大版本号,缺少索引优化。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```sql
|
|
||||||
-- 添加索引
|
|
||||||
CREATE INDEX IX_TenantRoutes_Version ON "TenantRoutes" ("Version" DESC);
|
|
||||||
|
|
||||||
-- 或使用 MAX 聚合
|
|
||||||
SELECT MAX("Version") FROM "TenantRoutes";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 PostgreSQL NOTIFY 连接管理
|
|
||||||
|
|
||||||
**文件位置:** `src/Data/GatewayDbContext.cs:72-75`
|
|
||||||
```csharp
|
|
||||||
using var connection = new NpgsqlConnection(connectionString);
|
|
||||||
connection.Open();
|
|
||||||
using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
|
|
||||||
cmd.ExecuteNonQuery();
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 每次保存变更都创建新的数据库连接发送通知,连接开销大。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 使用连接池中的连接
|
|
||||||
- 或复用 `PgSqlConfigChangeListener` 中的连接发送通知
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、脆弱区域 🟠
|
|
||||||
|
|
||||||
### 4.1 租户路由外键约束
|
|
||||||
|
|
||||||
**文件位置:** `src/Migrations/20260201120312_InitialCreate.cs:83-89`
|
|
||||||
```csharp
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_TenantRoutes_Tenants_TenantCode",
|
|
||||||
column: x => x.TenantCode,
|
|
||||||
principalTable: "Tenants",
|
|
||||||
principalColumn: "TenantCode",
|
|
||||||
onDelete: ReferentialAction.Restrict);
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** `TenantRoutes.TenantCode` 有外键约束,但全局路由(`IsGlobal=true`)时 `TenantCode` 可为空字符串,可能导致数据一致性问题。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 全局路由使用特定的占位符(如 "GLOBAL")
|
|
||||||
- 或修改外键约束为条件约束
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 健康检查配置硬编码
|
|
||||||
|
|
||||||
**文件位置:** `src/Config/DatabaseClusterConfigProvider.cs:77-86`
|
|
||||||
```csharp
|
|
||||||
HealthCheck = new HealthCheckConfig
|
|
||||||
{
|
|
||||||
Active = new ActiveHealthCheckConfig
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
Interval = TimeSpan.FromSeconds(30),
|
|
||||||
Timeout = TimeSpan.FromSeconds(5),
|
|
||||||
Path = "/health"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 健康检查路径和间隔硬编码,不同服务可能需要不同的健康检查配置。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 将健康检查配置存储在数据库
|
|
||||||
- 或在模型中添加健康检查配置字段
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.3 端口选择逻辑
|
|
||||||
|
|
||||||
**文件位置:** `src/Controllers/PendingServicesController.cs:119-120`
|
|
||||||
```csharp
|
|
||||||
var discoveredPorts = JsonSerializer.Deserialize<List<int>>(pendingService.DiscoveredPorts) ?? new List<int>();
|
|
||||||
var primaryPort = discoveredPorts.FirstOrDefault() > 0 ? discoveredPorts.First() : 80;
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 简单选择第一个端口作为主端口,可能不适合所有服务场景。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 支持端口选择策略配置
|
|
||||||
- 优先选择知名端口(如 80, 443, 8080)
|
|
||||||
- 允许用户在审批时选择端口
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.4 异常处理不完整
|
|
||||||
|
|
||||||
**文件位置:** `src/Services/PgSqlConfigChangeListener.cs:59-62`
|
|
||||||
```csharp
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to initialize PgSql listener");
|
|
||||||
// 未重试或终止服务
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 初始化失败后仅记录日志,服务继续运行但功能不完整。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to initialize PgSql listener, retrying in 5 seconds...");
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
|
||||||
await InitializeListenerAsync(stoppingToken); // 重试
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.5 状态变更无事务保护
|
|
||||||
|
|
||||||
**文件位置:** `src/Controllers/PendingServicesController.cs:137-145`
|
|
||||||
```csharp
|
|
||||||
db.ServiceInstances.Add(newInstance);
|
|
||||||
pendingService.Status = (int)PendingServiceStatus.Approved;
|
|
||||||
// ...
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题描述:** 创建实例和更新状态在同一事务中,但如果缓存重载失败,数据可能不一致。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 使用 TransactionScope 或数据库事务明确边界
|
|
||||||
- 添加补偿机制处理失败情况
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、可维护性问题 🟡
|
|
||||||
|
|
||||||
### 5.1 日志结构不统一
|
|
||||||
|
|
||||||
**问题描述:** 日志消息格式不统一,有的包含结构化数据,有的仅是文本。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 制定统一的日志格式规范
|
|
||||||
- 使用结构化日志模板:`LogInformation("Operation {Operation} completed for {Entity} with ID {Id}", "Create", "Route", route.Id)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.2 缺少单元测试
|
|
||||||
|
|
||||||
**问题描述:** 项目中未发现测试项目,核心逻辑缺少测试覆盖。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
- 创建 `tests/YarpGateway.Tests/` 测试项目
|
|
||||||
- 对以下核心组件编写单元测试:
|
|
||||||
- `RouteCache` - 路由查找逻辑
|
|
||||||
- `JwtTransformMiddleware` - JWT 解析逻辑
|
|
||||||
- `DistributedWeightedRoundRobinPolicy` - 负载均衡算法
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.3 配置验证缺失
|
|
||||||
|
|
||||||
**文件位置:** `src/Config/JwtConfig.cs`, `src/Config/RedisConfig.cs`
|
|
||||||
|
|
||||||
**问题描述:** 配置类没有验证逻辑,无效配置可能导致运行时错误。
|
|
||||||
|
|
||||||
**改进建议:**
|
|
||||||
```csharp
|
|
||||||
public class JwtConfig : IValidatableObject
|
|
||||||
{
|
|
||||||
public string Authority { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(Authority))
|
|
||||||
yield return new ValidationResult("Authority is required", new[] { nameof(Authority) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、改进优先级建议
|
|
||||||
|
|
||||||
| 优先级 | 问题 | 风险等级 | 建议处理时间 |
|
|
||||||
|--------|------|----------|--------------|
|
|
||||||
| P0 | 硬编码凭据泄露 | 严重 | 立即修复 |
|
|
||||||
| P0 | JWT未验证 | 严重 | 立即修复 |
|
|
||||||
| P0 | API无认证保护 | 严重 | 立即修复 |
|
|
||||||
| P1 | 租户ID信任问题 | 高 | 1周内 |
|
|
||||||
| P1 | ID生成策略 | 高 | 1周内 |
|
|
||||||
| P2 | 负载均衡锁竞争 | 中 | 2周内 |
|
|
||||||
| P2 | 路由缓存优化 | 中 | 2周内 |
|
|
||||||
| P3 | DTO内嵌定义 | 低 | 1个月内 |
|
|
||||||
| P3 | 缺少单元测试 | 低 | 持续改进 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、总结
|
|
||||||
|
|
||||||
本项目存在多个**严重安全漏洞**,主要涉及:
|
|
||||||
1. 敏感信息硬编码
|
|
||||||
2. 认证授权缺失
|
|
||||||
3. 输入验证不足
|
|
||||||
|
|
||||||
技术债务主要集中在代码组织、异常处理和性能优化方面。建议优先处理安全相关问题,然后逐步优化性能和可维护性。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*文档由自动化分析生成,建议人工复核后纳入迭代计划。*
|
|
||||||
@ -1,690 +0,0 @@
|
|||||||
# YARP Gateway 编码约定文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本文档记录了 YARP Gateway 项目的编码约定和最佳实践,旨在帮助开发人员理解和遵循项目规范。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 代码风格
|
|
||||||
|
|
||||||
### 1.1 命名约定
|
|
||||||
|
|
||||||
#### 类和接口命名
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 接口:使用 I 前缀 + PascalCase
|
|
||||||
public interface IRouteCache
|
|
||||||
{
|
|
||||||
Task InitializeAsync();
|
|
||||||
Task ReloadAsync();
|
|
||||||
RouteInfo? GetRoute(string tenantCode, string serviceName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实现类:PascalCase,描述性名称
|
|
||||||
public class RouteCache : IRouteCache
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配置类:以 Config 后缀
|
|
||||||
public class RedisConfig
|
|
||||||
{
|
|
||||||
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
|
|
||||||
public int Database { get; set; } = 0;
|
|
||||||
public string InstanceName { get; set; } = "YarpGateway";
|
|
||||||
}
|
|
||||||
|
|
||||||
// DTO 类:以 Dto 后缀
|
|
||||||
public class CreateTenantDto
|
|
||||||
{
|
|
||||||
public string TenantCode { get; set; } = string.Empty;
|
|
||||||
public string TenantName { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数据模型:Gw 前缀标识网关实体
|
|
||||||
public class GwTenantRoute
|
|
||||||
{
|
|
||||||
public long Id { get; set; }
|
|
||||||
public string TenantCode { get; set; } = string.Empty;
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 私有字段命名
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用下划线前缀 + camelCase
|
|
||||||
public class TenantRoutingMiddleware
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
private readonly IRouteCache _routeCache;
|
|
||||||
private readonly ILogger<TenantRoutingMiddleware> _logger;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:下划线前缀清晰区分私有字段和局部变量,避免 `this.` 的频繁使用。
|
|
||||||
|
|
||||||
#### 方法命名
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 异步方法:Async 后缀
|
|
||||||
public async Task InitializeAsync()
|
|
||||||
public async Task ReloadAsync()
|
|
||||||
private async Task LoadFromDatabaseAsync()
|
|
||||||
|
|
||||||
// 同步方法:动词开头
|
|
||||||
public RouteInfo? GetRoute(string tenantCode, string serviceName)
|
|
||||||
private string ExtractServiceName(string path)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 文件组织
|
|
||||||
|
|
||||||
项目采用按功能分层的方式组织代码:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── Config/ # 配置类和配置提供者
|
|
||||||
├── Controllers/ # API 控制器
|
|
||||||
├── Data/ # 数据库上下文和工厂
|
|
||||||
├── DynamicProxy/ # 动态代理配置
|
|
||||||
├── LoadBalancing/ # 负载均衡策略
|
|
||||||
├── Metrics/ # 指标收集
|
|
||||||
├── Middleware/ # 中间件
|
|
||||||
├── Migrations/ # 数据库迁移
|
|
||||||
├── Models/ # 数据模型
|
|
||||||
└── Services/ # 业务服务
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:按功能分层便于代码定位,降低耦合度。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 依赖注入模式
|
|
||||||
|
|
||||||
### 2.1 服务注册
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Program.cs 中的服务注册
|
|
||||||
|
|
||||||
// 配置选项模式
|
|
||||||
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection("Jwt"));
|
|
||||||
builder.Services.Configure<RedisConfig>(builder.Configuration.GetSection("Redis"));
|
|
||||||
|
|
||||||
// 直接注册配置实例(当需要直接使用配置对象时)
|
|
||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisConfig>>().Value);
|
|
||||||
|
|
||||||
// DbContext 使用工厂模式
|
|
||||||
builder.Services.AddDbContextFactory<GatewayDbContext>(options =>
|
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
|
|
||||||
);
|
|
||||||
|
|
||||||
// 单例服务(无状态或线程安全)
|
|
||||||
builder.Services.AddSingleton<DatabaseRouteConfigProvider>();
|
|
||||||
builder.Services.AddSingleton<DatabaseClusterConfigProvider>();
|
|
||||||
builder.Services.AddSingleton<IRouteCache, RouteCache>();
|
|
||||||
|
|
||||||
// 接口与实现分离注册
|
|
||||||
builder.Services.AddSingleton<IRedisConnectionManager, RedisConnectionManager>();
|
|
||||||
|
|
||||||
// 后台服务
|
|
||||||
builder.Services.AddHostedService<PgSqlConfigChangeListener>();
|
|
||||||
builder.Services.AddHostedService<KubernetesPendingSyncService>();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 依赖注入构造函数模式
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class RouteCache : IRouteCache
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
|
||||||
private readonly ILogger<RouteCache> _logger;
|
|
||||||
|
|
||||||
public RouteCache(
|
|
||||||
IDbContextFactory<GatewayDbContext> dbContextFactory,
|
|
||||||
ILogger<RouteCache> logger)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**模式要点**:
|
|
||||||
1. 所有依赖通过构造函数注入
|
|
||||||
2. 使用 `readonly` 修饰私有字段
|
|
||||||
3. 依赖项按类别排序(框架 → 基础设施 → 业务服务)
|
|
||||||
|
|
||||||
**原因**:构造函数注入确保依赖不可变,便于测试和依赖管理。
|
|
||||||
|
|
||||||
### 2.3 IDbContextFactory 模式
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 在 Singleton 服务中使用 DbContextFactory
|
|
||||||
public class RouteCache : IRouteCache
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
private async Task LoadFromDatabaseAsync()
|
|
||||||
{
|
|
||||||
// 使用 using 确保上下文正确释放
|
|
||||||
using var db = _dbContextFactory.CreateDbContext();
|
|
||||||
var routes = await db.TenantRoutes
|
|
||||||
.Where(r => r.Status == 1 && !r.IsDeleted)
|
|
||||||
.ToListAsync();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在 BackgroundService 中使用 Scope
|
|
||||||
public class KubernetesPendingSyncService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
|
||||||
|
|
||||||
private async Task SyncPendingServicesAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
// 创建作用域以获取 Scoped 服务
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<GatewayDbContext>>();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:`IDbContextFactory` 避免了 Singleton 服务直接持有 DbContext 的生命周期问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 配置管理模式
|
|
||||||
|
|
||||||
### 3.1 配置类定义
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 简单 POCO 配置类
|
|
||||||
namespace YarpGateway.Config;
|
|
||||||
|
|
||||||
public class JwtConfig
|
|
||||||
{
|
|
||||||
public string Authority { get; set; } = string.Empty;
|
|
||||||
public string Audience { get; set; } = string.Empty;
|
|
||||||
public bool ValidateIssuer { get; set; } = true;
|
|
||||||
public bool ValidateAudience { get; set; } = true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 配置绑定和注入
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Program.cs 中绑定配置
|
|
||||||
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection("Jwt"));
|
|
||||||
|
|
||||||
// 通过 IOptions<T> 注入
|
|
||||||
public class JwtTransformMiddleware
|
|
||||||
{
|
|
||||||
private readonly JwtConfig _jwtConfig;
|
|
||||||
|
|
||||||
public JwtTransformMiddleware(
|
|
||||||
RequestDelegate next,
|
|
||||||
IOptions<JwtConfig> jwtConfig, // 使用 IOptions<T>
|
|
||||||
ILogger<JwtTransformMiddleware> logger)
|
|
||||||
{
|
|
||||||
_jwtConfig = jwtConfig.Value; // 获取实际配置值
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 动态配置更新
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 配置变更通知通道
|
|
||||||
public static class ConfigNotifyChannel
|
|
||||||
{
|
|
||||||
public const string GatewayConfigChanged = "gateway_config_changed";
|
|
||||||
}
|
|
||||||
|
|
||||||
// DbContext 在保存时检测变更并通知
|
|
||||||
public class GatewayDbContext : DbContext
|
|
||||||
{
|
|
||||||
private bool _configChangeDetected;
|
|
||||||
|
|
||||||
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
DetectConfigChanges();
|
|
||||||
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
|
||||||
if (_configChangeDetected)
|
|
||||||
{
|
|
||||||
await NotifyConfigChangedAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DetectConfigChanges()
|
|
||||||
{
|
|
||||||
var entries = ChangeTracker.Entries()
|
|
||||||
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
|
|
||||||
.Where(e => e.Entity is GwTenantRoute or GwServiceInstance or GwTenant);
|
|
||||||
_configChangeDetected = entries.Any();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:使用 PostgreSQL NOTIFY/LISTEN 实现配置热更新,避免轮询。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 错误处理方式
|
|
||||||
|
|
||||||
### 4.1 中间件错误处理
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class JwtTransformMiddleware
|
|
||||||
{
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
// 快速失败模式:前置条件检查后直接调用 next
|
|
||||||
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
|
||||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
|
|
||||||
{
|
|
||||||
await _next(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 业务逻辑
|
|
||||||
var jwtHandler = new JwtSecurityTokenHandler();
|
|
||||||
var jwtToken = jwtHandler.ReadJwtToken(token);
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// 记录错误但不中断请求流程
|
|
||||||
_logger.LogError(ex, "Failed to parse JWT token");
|
|
||||||
}
|
|
||||||
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 控制器错误处理
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[HttpPost("{id}/assign")]
|
|
||||||
public async Task<IActionResult> AssignService(long id, [FromBody] AssignServiceRequest request)
|
|
||||||
{
|
|
||||||
await using var db = _dbContextFactory.CreateDbContext();
|
|
||||||
|
|
||||||
// 早期返回模式
|
|
||||||
var pendingService = await db.PendingServiceDiscoveries.FindAsync(id);
|
|
||||||
if (pendingService == null || pendingService.IsDeleted)
|
|
||||||
{
|
|
||||||
return NotFound(new { message = "Pending service not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingService.Status != (int)PendingServiceStatus.Pending)
|
|
||||||
{
|
|
||||||
return BadRequest(new { message = $"Service is already {((PendingServiceStatus)pendingService.Status)}, cannot assign" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(request.ClusterId))
|
|
||||||
{
|
|
||||||
return BadRequest(new { message = "ClusterId is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 业务逻辑...
|
|
||||||
return Ok(new { success = true, message = "..." });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**模式要点**:
|
|
||||||
1. 使用早期返回(Guard Clauses)减少嵌套
|
|
||||||
2. 返回结构化的错误信息
|
|
||||||
3. 使用 HTTP 状态码语义
|
|
||||||
|
|
||||||
### 4.3 后台服务错误处理
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await SyncPendingServicesAsync(stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// 记录错误但继续运行
|
|
||||||
_logger.LogError(ex, "Error during K8s pending service sync");
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(_syncInterval, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:后台服务不应因单次错误而终止,需具备自恢复能力。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 日志记录约定
|
|
||||||
|
|
||||||
### 5.1 结构化日志
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用 Serilog 结构化日志
|
|
||||||
_logger.LogInformation("Route cache initialized: {GlobalCount} global routes, {TenantCount} tenant routes",
|
|
||||||
_globalRoutes.Count, _tenantRoutes.Count);
|
|
||||||
|
|
||||||
_logger.LogWarning("No route found for: {Tenant}/{Service}", tenantCode, serviceName);
|
|
||||||
|
|
||||||
_logger.LogError(ex, "Redis connection failed");
|
|
||||||
|
|
||||||
_logger.LogDebug("Released lock for key: {Key}", _key);
|
|
||||||
```
|
|
||||||
|
|
||||||
**模式要点**:
|
|
||||||
1. 使用占位符 `{PropertyName}` 而非字符串插值
|
|
||||||
2. 日志消息使用常量,便于聚合分析
|
|
||||||
3. 包含足够的上下文信息
|
|
||||||
|
|
||||||
### 5.2 Serilog 配置
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Program.cs
|
|
||||||
builder.Host.UseSerilog(
|
|
||||||
(context, services, configuration) =>
|
|
||||||
configuration
|
|
||||||
.ReadFrom.Configuration(context.Configuration)
|
|
||||||
.ReadFrom.Services(services)
|
|
||||||
.Enrich.FromLogContext()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 日志级别使用
|
|
||||||
|
|
||||||
| 级别 | 使用场景 |
|
|
||||||
|------|----------|
|
|
||||||
| `LogDebug` | 详细调试信息,生产环境通常关闭 |
|
|
||||||
| `LogInformation` | 正常业务流程关键节点 |
|
|
||||||
| `LogWarning` | 可恢复的异常情况 |
|
|
||||||
| `LogError` | 错误需要关注但不影响整体运行 |
|
|
||||||
| `LogFatal` | 致命错误,应用无法继续运行 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 异步编程模式
|
|
||||||
|
|
||||||
### 6.1 async/await 使用
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 正确:异步方法使用 Async 后缀
|
|
||||||
public async Task InitializeAsync()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Initializing route cache from database...");
|
|
||||||
await LoadFromDatabaseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正确:使用 ConfigureAwait(false) 在库代码中
|
|
||||||
private async Task LoadFromDatabaseAsync()
|
|
||||||
{
|
|
||||||
using var db = _dbContextFactory.CreateDbContext();
|
|
||||||
var routes = await db.TenantRoutes
|
|
||||||
.Where(r => r.Status == 1 && !r.IsDeleted)
|
|
||||||
.ToListAsync();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 CancellationToken 使用
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 控制器方法
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetPendingServices(
|
|
||||||
[FromQuery] int page = 1,
|
|
||||||
[FromQuery] int pageSize = 10,
|
|
||||||
[FromQuery] int? status = null)
|
|
||||||
{
|
|
||||||
await using var db = _dbContextFactory.CreateDbContext();
|
|
||||||
// EF Core 自动处理 CancellationToken
|
|
||||||
var total = await query.CountAsync();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// 后台服务
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
await Task.Delay(_syncInterval, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 并发控制
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用 ReaderWriterLockSlim 保护读写
|
|
||||||
public class RouteCache : IRouteCache
|
|
||||||
{
|
|
||||||
private readonly ReaderWriterLockSlim _lock = new();
|
|
||||||
|
|
||||||
public RouteInfo? GetRoute(string tenantCode, string serviceName)
|
|
||||||
{
|
|
||||||
_lock.EnterUpgradeableReadLock();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 读取逻辑
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_lock.ExitUpgradeableReadLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadFromDatabaseAsync()
|
|
||||||
{
|
|
||||||
_lock.EnterWriteLock();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 写入逻辑
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_lock.ExitWriteLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 SemaphoreSlim 进行异步锁定
|
|
||||||
public class DatabaseRouteConfigProvider
|
|
||||||
{
|
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
|
||||||
|
|
||||||
public async Task ReloadAsync()
|
|
||||||
{
|
|
||||||
await _lock.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await LoadConfigInternalAsync();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_lock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
- `ReaderWriterLockSlim` 支持多读单写,适合读多写少场景
|
|
||||||
- `SemaphoreSlim` 支持异步等待,适合异步方法
|
|
||||||
|
|
||||||
### 6.4 Redis 分布式锁模式
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public async Task<IDisposable> AcquireLockAsync(string key, TimeSpan? expiry = null)
|
|
||||||
{
|
|
||||||
var redis = GetConnection();
|
|
||||||
var db = redis.GetDatabase();
|
|
||||||
var lockKey = $"lock:{_config.InstanceName}:{key}";
|
|
||||||
var lockValue = Environment.MachineName + ":" + Process.GetCurrentProcess().Id;
|
|
||||||
|
|
||||||
var acquired = await db.StringSetAsync(lockKey, lockValue, expiryTime, When.NotExists);
|
|
||||||
|
|
||||||
if (!acquired)
|
|
||||||
{
|
|
||||||
// 退避重试
|
|
||||||
var backoff = TimeSpan.FromMilliseconds(100);
|
|
||||||
while (!acquired && retryCount < maxRetries)
|
|
||||||
{
|
|
||||||
await Task.Delay(backoff);
|
|
||||||
acquired = await db.StringSetAsync(lockKey, lockValue, expiryTime, When.NotExists);
|
|
||||||
retryCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RedisLock(db, lockKey, lockValue, _logger);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 中间件模式
|
|
||||||
|
|
||||||
### 7.1 标准中间件结构
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class TenantRoutingMiddleware
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
private readonly IRouteCache _routeCache;
|
|
||||||
private readonly ILogger<TenantRoutingMiddleware> _logger;
|
|
||||||
|
|
||||||
// 构造函数注入依赖
|
|
||||||
public TenantRoutingMiddleware(
|
|
||||||
RequestDelegate next,
|
|
||||||
IRouteCache routeCache,
|
|
||||||
ILogger<TenantRoutingMiddleware> logger)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_routeCache = routeCache;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvokeAsync 方法签名固定
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
// 1. 前置处理
|
|
||||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
|
||||||
|
|
||||||
// 2. 快速返回
|
|
||||||
if (string.IsNullOrEmpty(tenantId))
|
|
||||||
{
|
|
||||||
await _next(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 业务逻辑
|
|
||||||
var route = _routeCache.GetRoute(tenantId, serviceName);
|
|
||||||
|
|
||||||
// 4. 设置上下文
|
|
||||||
context.Items["DynamicClusterId"] = route.ClusterId;
|
|
||||||
|
|
||||||
// 5. 调用下一个中间件
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 中间件注册顺序
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Program.cs
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
app.UseCors("AllowFrontend");
|
|
||||||
app.UseMiddleware<JwtTransformMiddleware>(); // JWT 解析
|
|
||||||
app.UseMiddleware<TenantRoutingMiddleware>(); // 租户路由
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
app.MapReverseProxy();
|
|
||||||
```
|
|
||||||
|
|
||||||
**顺序原因**:
|
|
||||||
1. CORS 需最先处理跨域请求
|
|
||||||
2. JWT 中间件解析用户信息供后续使用
|
|
||||||
3. 租户路由根据用户信息选择目标服务
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 控制器约定
|
|
||||||
|
|
||||||
### 8.1 控制器结构
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/gateway")]
|
|
||||||
public class GatewayConfigController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
|
||||||
private readonly IRouteCache _routeCache;
|
|
||||||
|
|
||||||
public GatewayConfigController(
|
|
||||||
IDbContextFactory<GatewayDbContext> dbContextFactory,
|
|
||||||
IRouteCache routeCache)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
_routeCache = routeCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Tenants
|
|
||||||
// 租户相关端点
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Routes
|
|
||||||
// 路由相关端点
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 端点命名
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// GET 集合
|
|
||||||
[HttpGet("tenants")]
|
|
||||||
public async Task<IActionResult> GetTenants(...) { }
|
|
||||||
|
|
||||||
// GET 单个
|
|
||||||
[HttpGet("tenants/{id}")]
|
|
||||||
public async Task<IActionResult> GetTenant(long id) { }
|
|
||||||
|
|
||||||
// POST 创建
|
|
||||||
[HttpPost("tenants")]
|
|
||||||
public async Task<IActionResult> CreateTenant([FromBody] CreateTenantDto dto) { }
|
|
||||||
|
|
||||||
// PUT 更新
|
|
||||||
[HttpPut("tenants/{id}")]
|
|
||||||
public async Task<IActionResult> UpdateTenant(long id, [FromBody] UpdateTenantDto dto) { }
|
|
||||||
|
|
||||||
// DELETE 删除
|
|
||||||
[HttpDelete("tenants/{id}")]
|
|
||||||
public async Task<IActionResult> DeleteTenant(long id) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 总结
|
|
||||||
|
|
||||||
本项目的编码约定遵循以下核心原则:
|
|
||||||
|
|
||||||
1. **一致性**:统一的命名和代码组织方式
|
|
||||||
2. **可测试性**:依赖注入和接口抽象便于测试
|
|
||||||
3. **可维护性**:清晰的结构和文档注释
|
|
||||||
4. **可观测性**:结构化日志和指标收集
|
|
||||||
5. **健壮性**:完善的错误处理和并发控制
|
|
||||||
|
|
||||||
遵循这些约定可以确保代码质量和团队协作效率。
|
|
||||||
@ -1,374 +0,0 @@
|
|||||||
# YARP 网关外部集成文档
|
|
||||||
|
|
||||||
## 1. PostgreSQL 数据库集成
|
|
||||||
|
|
||||||
### 概述
|
|
||||||
PostgreSQL 作为主数据库,存储网关配置数据,包括租户、路由、服务实例等信息。
|
|
||||||
|
|
||||||
### 连接配置
|
|
||||||
**配置位置**: `src/appsettings.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=***"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DbContext 配置
|
|
||||||
**文件**: `src/Data/GatewayDbContext.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 注册 DbContext 工厂
|
|
||||||
builder.Services.AddDbContextFactory<GatewayDbContext>(options =>
|
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据模型
|
|
||||||
| 实体 | 表名 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `GwTenant` | Tenants | 租户信息 |
|
|
||||||
| `GwTenantRoute` | TenantRoutes | 租户路由配置 |
|
|
||||||
| `GwServiceInstance` | ServiceInstances | 服务实例(集群节点) |
|
|
||||||
| `GwPendingServiceDiscovery` | PendingServiceDiscoveries | K8s 待处理服务发现 |
|
|
||||||
|
|
||||||
### 配置变更通知机制
|
|
||||||
**文件**: `src/Config/ConfigNotifyChannel.cs`
|
|
||||||
|
|
||||||
使用 PostgreSQL `LISTEN/NOTIFY` 机制实现配置变更实时通知:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 发送通知(在 DbContext.SaveChangesAsync 中触发)
|
|
||||||
await using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
|
|
||||||
|
|
||||||
// 监听通知(在 PgSqlConfigChangeListener 中)
|
|
||||||
cmd.CommandText = $"LISTEN {ConfigNotifyChannel.GatewayConfigChanged}";
|
|
||||||
```
|
|
||||||
|
|
||||||
**监听服务**: `src/Services/PgSqlConfigChangeListener.cs`
|
|
||||||
- 监听 PostgreSQL NOTIFY 通道
|
|
||||||
- 检测配置版本变更
|
|
||||||
- 触发路由/集群配置热更新
|
|
||||||
- 提供 5 分钟兜底轮询机制
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Redis 集成
|
|
||||||
|
|
||||||
### 概述
|
|
||||||
Redis 用于分布式锁、路由缓存同步,确保多实例网关的配置一致性。
|
|
||||||
|
|
||||||
### 连接配置
|
|
||||||
**配置位置**: `src/Config/RedisConfig.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class RedisConfig
|
|
||||||
{
|
|
||||||
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=***";
|
|
||||||
public int Database { get; set; } = 0;
|
|
||||||
public string InstanceName { get; set; } = "YarpGateway";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 连接管理器
|
|
||||||
**文件**: `src/Services/RedisConnectionManager.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 注册 Redis 连接
|
|
||||||
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
|
||||||
{
|
|
||||||
var config = sp.GetRequiredService<RedisConfig>();
|
|
||||||
var connectionOptions = ConfigurationOptions.Parse(config.ConnectionString);
|
|
||||||
connectionOptions.AbortOnConnectFail = false;
|
|
||||||
connectionOptions.ConnectRetry = 3;
|
|
||||||
connectionOptions.ConnectTimeout = 5000;
|
|
||||||
connectionOptions.SyncTimeout = 3000;
|
|
||||||
connectionOptions.DefaultDatabase = config.Database;
|
|
||||||
|
|
||||||
return ConnectionMultiplexer.Connect(connectionOptions);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 分布式锁实现
|
|
||||||
**接口**: `IRedisConnectionManager`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public interface IRedisConnectionManager
|
|
||||||
{
|
|
||||||
IConnectionMultiplexer GetConnection();
|
|
||||||
Task<IDisposable> AcquireLockAsync(string key, TimeSpan? expiry = null);
|
|
||||||
Task<T> ExecuteInLockAsync<T>(string key, Func<Task<T>> func, TimeSpan? expiry = null);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**锁机制特性**:
|
|
||||||
- 基于键值对的分布式锁
|
|
||||||
- 自动过期时间(默认 10 秒)
|
|
||||||
- 指数退避重试策略
|
|
||||||
- Lua 脚本安全释放锁
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Kubernetes 服务发现集成
|
|
||||||
|
|
||||||
### 概述
|
|
||||||
通过自定义的 Fengling.ServiceDiscovery 包实现 Kubernetes 服务自动发现,将 K8s Service 自动注册为网关后端服务。
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
**文件**: `src/Program.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 添加 Kubernetes 服务发现
|
|
||||||
var useInClusterConfig = builder.Configuration.GetValue<bool>("ServiceDiscovery:UseInClusterConfig", true);
|
|
||||||
builder.Services.AddKubernetesServiceDiscovery(options =>
|
|
||||||
{
|
|
||||||
options.LabelSelector = "app.kubernetes.io/managed-by=yarp";
|
|
||||||
options.UseInClusterConfig = useInClusterConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddServiceDiscovery();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖包
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `Fengling.ServiceDiscovery.Core` | 服务发现核心接口 |
|
|
||||||
| `Fengling.ServiceDiscovery.Kubernetes` | Kubernetes 实现 |
|
|
||||||
| `Fengling.ServiceDiscovery.Static` | 静态配置实现 |
|
|
||||||
|
|
||||||
### 后台同步服务
|
|
||||||
**文件**: `src/Services/KubernetesPendingSyncService.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class KubernetesPendingSyncService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly TimeSpan _syncInterval = TimeSpan.FromSeconds(30);
|
|
||||||
private readonly TimeSpan _staleThreshold = TimeSpan.FromHours(24);
|
|
||||||
|
|
||||||
// 同步 K8s 服务到数据库待处理表
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**同步逻辑**:
|
|
||||||
1. 每 30 秒从 K8s API 获取服务列表
|
|
||||||
2. 对比数据库中的待处理服务记录
|
|
||||||
3. 新增/更新/清理过期服务
|
|
||||||
4. 标记不再存在的 K8s 服务
|
|
||||||
|
|
||||||
### 待处理服务数据模型
|
|
||||||
**文件**: `src/Models/GwPendingServiceDiscovery.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class GwPendingServiceDiscovery
|
|
||||||
{
|
|
||||||
public string K8sServiceName { get; set; } // K8s Service 名称
|
|
||||||
public string K8sNamespace { get; set; } // K8s 命名空间
|
|
||||||
public string K8sClusterIP { get; set; } // ClusterIP
|
|
||||||
public string DiscoveredPorts { get; set; } // JSON 序列化的端口列表
|
|
||||||
public string Labels { get; set; } // K8s 标签
|
|
||||||
public string AssignedClusterId { get; set; } // 分配的集群 ID
|
|
||||||
public int Status { get; set; } // 状态
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. JWT 认证集成
|
|
||||||
|
|
||||||
### 概述
|
|
||||||
网关解析 JWT Token,提取租户和用户信息,转换为下游服务可用的 HTTP 头。
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
**文件**: `src/Config/JwtConfig.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class JwtConfig
|
|
||||||
{
|
|
||||||
public string Authority { get; set; } = string.Empty; // 认证服务器地址
|
|
||||||
public string Audience { get; set; } = string.Empty; // 受众
|
|
||||||
public bool ValidateIssuer { get; set; } = true; // 验证签发者
|
|
||||||
public bool ValidateAudience { get; set; } = true; // 验证受众
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**配置示例** (`src/appsettings.json`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Jwt": {
|
|
||||||
"Authority": "https://your-auth-server.com",
|
|
||||||
"Audience": "fengling-gateway",
|
|
||||||
"ValidateIssuer": true,
|
|
||||||
"ValidateAudience": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### JWT 转换中间件
|
|
||||||
**文件**: `src/Middleware/JwtTransformMiddleware.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
|
||||||
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
|
|
||||||
{
|
|
||||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
|
||||||
var jwtToken = jwtHandler.ReadJwtToken(token);
|
|
||||||
|
|
||||||
// 提取声明并转换为 HTTP 头
|
|
||||||
var tenantId = jwtToken.Claims.FirstOrDefault(c => c.Type == "tenant")?.Value;
|
|
||||||
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
|
|
||||||
context.Request.Headers["X-Tenant-Id"] = tenantId;
|
|
||||||
context.Request.Headers["X-User-Id"] = userId;
|
|
||||||
context.Request.Headers["X-User-Name"] = userName;
|
|
||||||
context.Request.Headers["X-Roles"] = string.Join(",", roles);
|
|
||||||
}
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### JWT 声明到 HTTP 头映射
|
|
||||||
| JWT 声明类型 | HTTP 头 | 说明 |
|
|
||||||
|--------------|---------|------|
|
|
||||||
| `tenant` | `X-Tenant-Id` | 租户标识 |
|
|
||||||
| `ClaimTypes.NameIdentifier` | `X-User-Id` | 用户 ID |
|
|
||||||
| `ClaimTypes.Name` | `X-User-Name` | 用户名 |
|
|
||||||
| `ClaimTypes.Role` | `X-Roles` | 角色列表(逗号分隔) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 外部 API 和服务连接
|
|
||||||
|
|
||||||
### CORS 配置
|
|
||||||
**文件**: `src/appsettings.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Cors": {
|
|
||||||
"AllowedOrigins": [
|
|
||||||
"http://localhost:5173",
|
|
||||||
"http://127.0.0.1:5173",
|
|
||||||
"http://localhost:5174"
|
|
||||||
],
|
|
||||||
"AllowAnyOrigin": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 健康检查端点
|
|
||||||
**文件**: `src/Program.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
app.MapGet("/health", () => Results.Ok(new {
|
|
||||||
status = "healthy",
|
|
||||||
timestamp = DateTime.UtcNow
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 下游服务健康检查
|
|
||||||
**文件**: `src/Config/DatabaseClusterConfigProvider.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
HealthCheck = new HealthCheckConfig
|
|
||||||
{
|
|
||||||
Active = new ActiveHealthCheckConfig
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
Interval = TimeSpan.FromSeconds(30),
|
|
||||||
Timeout = TimeSpan.FromSeconds(5),
|
|
||||||
Path = "/health"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 动态代理配置
|
|
||||||
**文件**: `src/DynamicProxy/DynamicProxyConfigProvider.cs`
|
|
||||||
|
|
||||||
实现 `IProxyConfigProvider` 接口,从数据库动态加载路由和集群配置:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class DynamicProxyConfigProvider : IProxyConfigProvider
|
|
||||||
{
|
|
||||||
public IProxyConfig GetConfig() => _config;
|
|
||||||
|
|
||||||
public void UpdateConfig()
|
|
||||||
{
|
|
||||||
var routes = _routeProvider.GetRoutes();
|
|
||||||
var clusters = _clusterProvider.GetClusters();
|
|
||||||
_config = new InMemoryProxyConfig(routes, clusters, ...);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 集成架构图
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ 客户端请求 │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ YARP Gateway │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 中间件管道 │ │
|
|
||||||
│ │ CORS → JWT转换 → 租户路由 → Controllers → Proxy │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────┐ │
|
|
||||||
│ │RouteCache│ │ConfigProv│ │LoadBalancingPolicy │ │
|
|
||||||
│ └────┬─────┘ └────┬─────┘ └──────────────────────────┘ │
|
|
||||||
└───────┼─────────────┼────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
┌───────────────┼─────────────┼───────────────┐
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────────┐
|
|
||||||
│ PostgreSQL │ │ Redis │ │ K8s │ │ Auth Server │
|
|
||||||
│ │ │ │ │ API │ │ (JWT) │
|
|
||||||
│ - 租户配置 │ │ - 分布式锁│ │ │ │ │
|
|
||||||
│ - 路由配置 │ │ - 缓存 │ │ - Service │ │ - Token 签发 │
|
|
||||||
│ - 服务实例 │ │ │ │ - Pod │ │ - 声明信息 │
|
|
||||||
│ - NOTIFY机制 │ │ │ │ │ │ │
|
|
||||||
└───────────────┘ └───────────┘ └───────────┘ └───────────────────┘
|
|
||||||
│
|
|
||||||
│ LISTEN/NOTIFY
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────────────────────────────┐
|
|
||||||
│ 配置变更监听器 │
|
|
||||||
│ PgSqlConfigChangeListener + FallbackPolling │
|
|
||||||
└───────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 配置热更新流程
|
|
||||||
|
|
||||||
```
|
|
||||||
数据库配置变更
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
DbContext.SaveChangesAsync()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
NOTIFY gateway_config_changed
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PgSqlConfigChangeListener.OnNotification()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
RouteCache.ReloadAsync()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
DynamicProxyConfigProvider.UpdateConfig()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
YARP 配置生效(无需重启)
|
|
||||||
```
|
|
||||||
|
|
||||||
**兜底机制**: 每 5 分钟检查版本号,防止 NOTIFY 丢失导致配置不一致。
|
|
||||||
@ -1,368 +0,0 @@
|
|||||||
# 🔒 YARP 网关安全审计报告
|
|
||||||
|
|
||||||
> 审计日期:2026-02-28
|
|
||||||
> 审计范围:认证授权、注入漏洞、敏感信息、访问控制、配置安全
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 执行摘要
|
|
||||||
|
|
||||||
| 严重程度 | 数量 |
|
|
||||||
|---------|------|
|
|
||||||
| 🔴 严重 (CRITICAL) | 3 |
|
|
||||||
| 🟠 高危 (HIGH) | 3 |
|
|
||||||
| 🟡 中危 (MEDIUM) | 4 |
|
|
||||||
| 🟢 低危 (LOW) | 3 |
|
|
||||||
| **总计** | **13** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 严重漏洞
|
|
||||||
|
|
||||||
### 1. 硬编码数据库凭据泄露
|
|
||||||
|
|
||||||
**文件:** `src/appsettings.json` 第 19 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```json
|
|
||||||
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
|
|
||||||
```
|
|
||||||
|
|
||||||
**攻击场景:**
|
|
||||||
- 代码泄露或被推送到公开仓库时,攻击者直接获得数据库完整访问权限
|
|
||||||
- 可读取、修改、删除所有业务数据
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
// 使用环境变量或 Secret Manager
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
|
||||||
// 或使用 Azure Key Vault / AWS Secrets Manager
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 硬编码 Redis 凭据泄露
|
|
||||||
|
|
||||||
**文件:** `src/Config/RedisConfig.cs` 第 5 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```csharp
|
|
||||||
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
|
|
||||||
```
|
|
||||||
|
|
||||||
**攻击场景:**
|
|
||||||
- 攻击者可连接 Redis 服务器,读取缓存数据、修改路由配置、注入恶意数据
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
public string ConnectionString { get; set; } = string.Empty;
|
|
||||||
// 从环境变量或配置中心读取
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 管理 API 完全无认证保护
|
|
||||||
|
|
||||||
**文件:** `src/Controllers/GatewayConfigController.cs` 及 `src/Controllers/PendingServicesController.cs`
|
|
||||||
|
|
||||||
**问题描述:**
|
|
||||||
- 所有 API 端点均无 `[Authorize]` 特性
|
|
||||||
- `Program.cs` 中未配置 `AddAuthentication()` 和 `UseAuthentication()`
|
|
||||||
- 项目搜索未发现任何认证中间件
|
|
||||||
|
|
||||||
**攻击场景:**
|
|
||||||
```
|
|
||||||
# 攻击者可直接调用以下 API:
|
|
||||||
POST /api/gateway/tenants # 创建任意租户
|
|
||||||
DELETE /api/gateway/tenants/{id} # 删除租户
|
|
||||||
POST /api/gateway/routes # 创建恶意路由
|
|
||||||
POST /api/gateway/config/reload # 重载配置
|
|
||||||
DELETE /api/gateway/clusters/{id} # 删除服务集群
|
|
||||||
```
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
// Program.cs
|
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
||||||
.AddJwtBearer(options => { /* 配置 JWT 验证 */ });
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
// Controllers
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/gateway")]
|
|
||||||
[Authorize] // 添加认证要求
|
|
||||||
public class GatewayConfigController : ControllerBase
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟠 高危漏洞
|
|
||||||
|
|
||||||
### 4. JWT 签名验证缺失
|
|
||||||
|
|
||||||
**文件:** `src/Middleware/JwtTransformMiddleware.cs` 第 39-40 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```csharp
|
|
||||||
var jwtHandler = new JwtSecurityTokenHandler();
|
|
||||||
var jwtToken = jwtHandler.ReadJwtToken(token); // 仅读取,不验证!
|
|
||||||
```
|
|
||||||
|
|
||||||
**攻击场景:**
|
|
||||||
```python
|
|
||||||
# 攻击者可伪造任意 JWT
|
|
||||||
import jwt
|
|
||||||
fake_token = jwt.encode({"tenant": "admin-tenant", "sub": "admin"}, "any_secret", algorithm="HS256")
|
|
||||||
# 网关会接受这个伪造的 token
|
|
||||||
```
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
var validationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = _jwtConfig.Authority,
|
|
||||||
ValidAudience = _jwtConfig.Audience,
|
|
||||||
IssuerSigningKey = /* 从 Authority 获取公钥 */
|
|
||||||
};
|
|
||||||
|
|
||||||
var principal = jwtHandler.ValidateToken(token, validationParameters, out _);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 租户隔离可被 Header 注入绕过
|
|
||||||
|
|
||||||
**文件:** `src/Middleware/JwtTransformMiddleware.cs` 第 54 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```csharp
|
|
||||||
context.Request.Headers["X-Tenant-Id"] = tenantId;
|
|
||||||
```
|
|
||||||
|
|
||||||
**攻击场景:**
|
|
||||||
```bash
|
|
||||||
# 攻击者直接注入 Header 绕过 JWT
|
|
||||||
curl -H "X-Tenant-Id: target-tenant" \
|
|
||||||
-H "X-User-Id: admin" \
|
|
||||||
-H "X-Roles: admin" \
|
|
||||||
https://gateway/api/sensitive-data
|
|
||||||
```
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
// 在中间件开始时移除所有 X-* Header
|
|
||||||
foreach (var header in context.Request.Headers.Where(h => h.Key.StartsWith("X-")).ToList())
|
|
||||||
{
|
|
||||||
context.Request.Headers.Remove(header.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 然后再从 JWT 设置可信的 header
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. 租户路由信息泄露
|
|
||||||
|
|
||||||
**文件:** `src/Middleware/TenantRoutingMiddleware.cs` 第 44 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```csharp
|
|
||||||
_logger.LogWarning("Route not found - Tenant: {Tenant}, Service: {Service}", tenantId, serviceName);
|
|
||||||
```
|
|
||||||
|
|
||||||
**攻击场景:**
|
|
||||||
- 日志中记录租户 ID 和服务名,攻击者可通过日志收集系统架构信息
|
|
||||||
- 配合其他攻击进行侦察
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
- 敏感信息不应记录到普通日志
|
|
||||||
- 使用脱敏处理或仅记录哈希值
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 中危漏洞
|
|
||||||
|
|
||||||
### 7. 日志记录敏感连接信息
|
|
||||||
|
|
||||||
**文件:** `src/Services/RedisConnectionManager.cs` 第 44 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```csharp
|
|
||||||
_logger.LogInformation("Connected to Redis at {ConnectionString}", _config.ConnectionString);
|
|
||||||
```
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
_logger.LogInformation("Connected to Redis at {Host}",
|
|
||||||
configuration.EndPoints.FirstOrDefault()?.ToString());
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. CORS 凭据配置存在风险
|
|
||||||
|
|
||||||
**文件:** `src/Program.cs` 第 89-100 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```csharp
|
|
||||||
if (allowAnyOrigin)
|
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin();
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
policy.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowCredentials(); // 与 AllowAnyOrigin 不兼容
|
|
||||||
```
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
if (allowAnyOrigin)
|
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod();
|
|
||||||
// 不允许 AllowCredentials
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
policy.WithOrigins(allowedOrigins)
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowCredentials();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. 健康检查端点信息泄露
|
|
||||||
|
|
||||||
**文件:** `src/Program.cs` 第 115 行
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
// 添加访问限制或使用标准健康检查
|
|
||||||
builder.Services.AddHealthChecks();
|
|
||||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
|
||||||
{
|
|
||||||
ResponseWriter = async (c, r) =>
|
|
||||||
await c.Response.WriteAsync("healthy")
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. JWT Authority 使用占位符 URL
|
|
||||||
|
|
||||||
**文件:** `src/appsettings.json` 第 22 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```json
|
|
||||||
"Authority": "https://your-auth-server.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
- 强制要求配置有效的 Authority URL
|
|
||||||
- 启动时验证配置有效性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟢 低危漏洞
|
|
||||||
|
|
||||||
### 11. 可预测的 ID 生成
|
|
||||||
|
|
||||||
**文件:** `src/Controllers/GatewayConfigController.cs` 第 484-487 行
|
|
||||||
|
|
||||||
**问题代码:**
|
|
||||||
```csharp
|
|
||||||
private long GenerateId()
|
|
||||||
{
|
|
||||||
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
// 使用 GUID 或雪花算法
|
|
||||||
private long GenerateId() => SnowflakeIdGenerator.NextId();
|
|
||||||
// 或
|
|
||||||
private string GenerateId() => Guid.NewGuid().ToString("N");
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. 缺少输入验证
|
|
||||||
|
|
||||||
**文件:** `src/Controllers/GatewayConfigController.cs` 多处
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
public class CreateTenantDto
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
[RegularExpression(@"^[a-zA-Z0-9-]{1,50}$")]
|
|
||||||
public string TenantCode { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[StringLength(100, MinimumLength = 1)]
|
|
||||||
public string TenantName { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. 错误消息暴露内部信息
|
|
||||||
|
|
||||||
**文件:** `src/Controllers/PendingServicesController.cs` 第 116 行
|
|
||||||
|
|
||||||
**修复建议:**
|
|
||||||
```csharp
|
|
||||||
return BadRequest(new { message = "Invalid cluster configuration" });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 修复优先级建议
|
|
||||||
|
|
||||||
| 优先级 | 漏洞编号 | 修复时间建议 |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| P0 (立即) | #1, #2, #3 | 24小时内 |
|
|
||||||
| P1 (紧急) | #4, #5, #6 | 1周内 |
|
|
||||||
| P2 (重要) | #7, #8, #9, #10 | 2周内 |
|
|
||||||
| P3 (一般) | #11, #12, #13 | 1个月内 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ 安全加固建议
|
|
||||||
|
|
||||||
### 1. 认证授权
|
|
||||||
- 实施完整的 JWT 验证流程
|
|
||||||
- 为所有管理 API 添加 `[Authorize]`
|
|
||||||
- 实施基于角色的访问控制 (RBAC)
|
|
||||||
|
|
||||||
### 2. 配置安全
|
|
||||||
- 使用 Azure Key Vault / AWS Secrets Manager 管理密钥
|
|
||||||
- 移除所有硬编码凭据
|
|
||||||
- 生产环境禁用调试模式
|
|
||||||
|
|
||||||
### 3. 租户隔离
|
|
||||||
- 在网关层强制验证租户归属
|
|
||||||
- 使用加密签名验证内部 Header
|
|
||||||
- 实施租户数据隔离审计
|
|
||||||
|
|
||||||
### 4. 日志安全
|
|
||||||
- 敏感信息脱敏
|
|
||||||
- 限制日志访问权限
|
|
||||||
- 使用结构化日志便于审计
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*报告由安全审计生成,建议人工复核后纳入迭代计划。*
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
# YARP 网关技术栈文档
|
|
||||||
|
|
||||||
## 1. 语言和运行时
|
|
||||||
|
|
||||||
### .NET 版本
|
|
||||||
- **目标框架**: .NET 10.0
|
|
||||||
- **项目文件**: `src/YarpGateway.csproj`
|
|
||||||
- **SDK**: `Microsoft.NET.Sdk.Web`
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 核心框架
|
|
||||||
|
|
||||||
### YARP (Yet Another Reverse Proxy)
|
|
||||||
- **包**: `Yarp.ReverseProxy`
|
|
||||||
- **用途**: 微服务 API 网关核心反向代理引擎
|
|
||||||
- **主要功能**:
|
|
||||||
- 动态路由配置
|
|
||||||
- 负载均衡策略
|
|
||||||
- 健康检查
|
|
||||||
- 请求转发
|
|
||||||
|
|
||||||
### ASP.NET Core
|
|
||||||
- **用途**: Web 应用宿主框架
|
|
||||||
- **特性**:
|
|
||||||
- 依赖注入 (DI)
|
|
||||||
- 中间件管道
|
|
||||||
- 配置系统
|
|
||||||
- 日志集成
|
|
||||||
|
|
||||||
## 3. 主要依赖包
|
|
||||||
|
|
||||||
### 数据访问
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `Npgsql.EntityFrameworkCore.PostgreSQL` | PostgreSQL Entity Framework Core 提供程序 |
|
|
||||||
| `Microsoft.EntityFrameworkCore.Design` | EF Core 设计时工具(迁移) |
|
|
||||||
|
|
||||||
### 缓存与分布式锁
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `StackExchange.Redis` | Redis 客户端,用于分布式锁和缓存 |
|
|
||||||
|
|
||||||
### 认证授权
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `Microsoft.AspNetCore.Authentication.JwtBearer` | JWT Bearer 认证支持 |
|
|
||||||
|
|
||||||
### 日志
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `Serilog.AspNetCore` | Serilog ASP.NET Core 集成 |
|
|
||||||
| `Serilog.Sinks.Console` | 控制台日志输出 |
|
|
||||||
| `Serilog.Sinks.File` | 文件日志输出 |
|
|
||||||
|
|
||||||
### 服务发现(自定义包)
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `Fengling.ServiceDiscovery.Core` | 服务发现核心接口 |
|
|
||||||
| `Fengling.ServiceDiscovery.Kubernetes` | Kubernetes 服务发现实现 |
|
|
||||||
| `Fengling.ServiceDiscovery.Static` | 静态配置服务发现 |
|
|
||||||
|
|
||||||
## 4. 配置文件
|
|
||||||
|
|
||||||
### 主配置文件
|
|
||||||
**位置**: `src/appsettings.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Host=...;Port=...;Database=...;Username=...;Password=..."
|
|
||||||
},
|
|
||||||
"Jwt": {
|
|
||||||
"Authority": "https://your-auth-server.com",
|
|
||||||
"Audience": "fengling-gateway",
|
|
||||||
"ValidateIssuer": true,
|
|
||||||
"ValidateAudience": true
|
|
||||||
},
|
|
||||||
"Redis": {
|
|
||||||
"ConnectionString": "host:port",
|
|
||||||
"Database": 0,
|
|
||||||
"InstanceName": "YarpGateway"
|
|
||||||
},
|
|
||||||
"Cors": {
|
|
||||||
"AllowedOrigins": ["http://localhost:5173"],
|
|
||||||
"AllowAnyOrigin": false
|
|
||||||
},
|
|
||||||
"Kestrel": {
|
|
||||||
"Endpoints": {
|
|
||||||
"Http": { "Url": "http://0.0.0.0:8080" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Serilog": {
|
|
||||||
"MinimumLevel": "Information",
|
|
||||||
"WriteTo": [
|
|
||||||
{ "Name": "Console" },
|
|
||||||
{ "Name": "File", "Args": { "path": "logs/gateway-.log", "rollingInterval": "Day" } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置类
|
|
||||||
| 文件路径 | 类名 | 用途 |
|
|
||||||
|----------|------|------|
|
|
||||||
| `src/Config/JwtConfig.cs` | `JwtConfig` | JWT 认证配置 |
|
|
||||||
| `src/Config/RedisConfig.cs` | `RedisConfig` | Redis 连接配置 |
|
|
||||||
| `src/Config/ConfigNotifyChannel.cs` | `ConfigNotifyChannel` | PostgreSQL NOTIFY 通道常量 |
|
|
||||||
|
|
||||||
## 5. Docker 支持
|
|
||||||
|
|
||||||
### Dockerfile
|
|
||||||
**位置**: `Dockerfile`
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# 基础镜像
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
|
||||||
EXPOSE 8080
|
|
||||||
EXPOSE 8081
|
|
||||||
|
|
||||||
# 构建镜像
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|
||||||
# 多阶段构建...
|
|
||||||
|
|
||||||
# 最终镜像
|
|
||||||
FROM base AS final
|
|
||||||
ENTRYPOINT ["dotnet", "YarpGateway.dll"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker 配置
|
|
||||||
- **默认目标 OS**: Linux
|
|
||||||
- **暴露端口**: 8080 (HTTP), 8081 (HTTPS)
|
|
||||||
- **工作目录**: `/app`
|
|
||||||
|
|
||||||
## 6. 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── Config/ # 配置类
|
|
||||||
│ ├── JwtConfig.cs
|
|
||||||
│ ├── RedisConfig.cs
|
|
||||||
│ ├── ConfigNotifyChannel.cs
|
|
||||||
│ ├── DatabaseRouteConfigProvider.cs
|
|
||||||
│ └── DatabaseClusterConfigProvider.cs
|
|
||||||
├── Data/ # 数据访问层
|
|
||||||
│ ├── GatewayDbContext.cs
|
|
||||||
│ └── GatewayDbContextFactory.cs
|
|
||||||
├── DynamicProxy/ # 动态代理配置
|
|
||||||
│ └── DynamicProxyConfigProvider.cs
|
|
||||||
├── LoadBalancing/ # 负载均衡策略
|
|
||||||
│ └── DistributedWeightedRoundRobinPolicy.cs
|
|
||||||
├── Middleware/ # 中间件
|
|
||||||
│ ├── JwtTransformMiddleware.cs
|
|
||||||
│ └── TenantRoutingMiddleware.cs
|
|
||||||
├── Models/ # 数据模型
|
|
||||||
│ ├── GwTenant.cs
|
|
||||||
│ ├── GwTenantRoute.cs
|
|
||||||
│ ├── GwServiceInstance.cs
|
|
||||||
│ └── GwPendingServiceDiscovery.cs
|
|
||||||
├── Services/ # 业务服务
|
|
||||||
│ ├── RouteCache.cs
|
|
||||||
│ ├── RedisConnectionManager.cs
|
|
||||||
│ ├── KubernetesPendingSyncService.cs
|
|
||||||
│ └── PgSqlConfigChangeListener.cs
|
|
||||||
├── Program.cs # 应用入口
|
|
||||||
├── appsettings.json # 配置文件
|
|
||||||
└── YarpGateway.csproj # 项目文件
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 中间件管道
|
|
||||||
|
|
||||||
请求处理管道顺序(`Program.cs`):
|
|
||||||
|
|
||||||
1. **CORS** - 跨域请求处理
|
|
||||||
2. **JwtTransformMiddleware** - JWT 解析与转换
|
|
||||||
3. **TenantRoutingMiddleware** - 租户路由解析
|
|
||||||
4. **Controllers** - API 控制器
|
|
||||||
5. **ReverseProxy** - YARP 反向代理
|
|
||||||
|
|
||||||
## 8. 托管与部署
|
|
||||||
|
|
||||||
### Kestrel 配置
|
|
||||||
- 监听地址: `http://0.0.0.0:8080`
|
|
||||||
- 支持 Docker 容器化部署
|
|
||||||
- 支持 Kubernetes 集群部署
|
|
||||||
@ -1,465 +0,0 @@
|
|||||||
# YARP Gateway 目录结构文档
|
|
||||||
|
|
||||||
## 1. 目录布局
|
|
||||||
|
|
||||||
```
|
|
||||||
fengling-gateway/
|
|
||||||
├── .planning/ # 规划文档目录
|
|
||||||
│ └── codebase/ # 代码库分析文档
|
|
||||||
│ ├── ARCHITECTURE.md # 架构文档
|
|
||||||
│ └── STRUCTURE.md # 本文档
|
|
||||||
│
|
|
||||||
├── src/ # 源代码目录
|
|
||||||
│ ├── Config/ # 配置类和提供者
|
|
||||||
│ ├── Controllers/ # API 控制器
|
|
||||||
│ ├── Data/ # 数据访问层
|
|
||||||
│ ├── DynamicProxy/ # YARP 动态代理
|
|
||||||
│ ├── LoadBalancing/ # 负载均衡策略
|
|
||||||
│ ├── Migrations/ # 数据库迁移
|
|
||||||
│ ├── Metrics/ # 监控指标
|
|
||||||
│ ├── Middleware/ # 中间件
|
|
||||||
│ ├── Models/ # 数据模型
|
|
||||||
│ ├── Properties/ # 项目属性
|
|
||||||
│ ├── Services/ # 业务服务
|
|
||||||
│ ├── Program.cs # 程序入口
|
|
||||||
│ ├── YarpGateway.csproj # 项目文件
|
|
||||||
│ ├── appsettings.json # 配置文件
|
|
||||||
│ └── appsettings.Development.json # 开发环境配置
|
|
||||||
│
|
|
||||||
└── (根目录其他文件)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 详细目录说明
|
|
||||||
|
|
||||||
### 2.1 Config/ - 配置层
|
|
||||||
|
|
||||||
**路径**: `src/Config/`
|
|
||||||
|
|
||||||
**用途**: 存放配置模型和配置提供者
|
|
||||||
|
|
||||||
| 文件 | 行数 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `JwtConfig.cs` | 10 | JWT 认证配置模型,包含 Authority、Audience 等属性 |
|
|
||||||
| `RedisConfig.cs` | 9 | Redis 连接配置模型,包含连接字符串、数据库索引等 |
|
|
||||||
| `ConfigNotifyChannel.cs` | 7 | PostgreSQL NOTIFY 通道名称常量定义 |
|
|
||||||
| `DatabaseRouteConfigProvider.cs` | 84 | 从数据库加载路由配置,转换为 YARP RouteConfig |
|
|
||||||
| `DatabaseClusterConfigProvider.cs` | 100 | 从数据库加载集群配置,管理服务实例列表 |
|
|
||||||
|
|
||||||
**设计特点**:
|
|
||||||
- 配置类使用 POCO 模型,通过 Options 模式注入
|
|
||||||
- Provider 类使用单例模式,支持热重载
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 Controllers/ - 控制器层
|
|
||||||
|
|
||||||
**路径**: `src/Controllers/`
|
|
||||||
|
|
||||||
**用途**: RESTful API 端点
|
|
||||||
|
|
||||||
| 文件 | 行数 | 路由前缀 | 用途 |
|
|
||||||
|------|------|----------|------|
|
|
||||||
| `GatewayConfigController.cs` | 489 | `/api/gateway` | 网关配置管理 API |
|
|
||||||
| `PendingServicesController.cs` | 210 | `/api/gateway/pending-services` | 待处理服务管理 API |
|
|
||||||
|
|
||||||
**GatewayConfigController 端点**:
|
|
||||||
|
|
||||||
| 方法 | 路由 | 功能 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/tenants` | 获取租户列表(分页) |
|
|
||||||
| GET | `/tenants/{id}` | 获取单个租户 |
|
|
||||||
| POST | `/tenants` | 创建租户 |
|
|
||||||
| PUT | `/tenants/{id}` | 更新租户 |
|
|
||||||
| DELETE | `/tenants/{id}` | 删除租户 |
|
|
||||||
| GET | `/routes` | 获取路由列表(分页) |
|
|
||||||
| GET | `/routes/global` | 获取全局路由 |
|
|
||||||
| GET | `/routes/tenant/{tenantCode}` | 获取租户路由 |
|
|
||||||
| POST | `/routes` | 创建路由 |
|
|
||||||
| PUT | `/routes/{id}` | 更新路由 |
|
|
||||||
| DELETE | `/routes/{id}` | 删除路由 |
|
|
||||||
| GET | `/clusters` | 获取集群列表 |
|
|
||||||
| GET | `/clusters/{clusterId}` | 获取集群详情 |
|
|
||||||
| POST | `/clusters` | 创建集群 |
|
|
||||||
| DELETE | `/clusters/{clusterId}` | 删除集群 |
|
|
||||||
| GET | `/clusters/{clusterId}/instances` | 获取实例列表 |
|
|
||||||
| POST | `/clusters/{clusterId}/instances` | 添加实例 |
|
|
||||||
| DELETE | `/instances/{id}` | 删除实例 |
|
|
||||||
| POST | `/config/reload` | 重载配置 |
|
|
||||||
| GET | `/config/status` | 获取配置状态 |
|
|
||||||
| GET | `/config/versions` | 获取版本信息 |
|
|
||||||
| GET | `/stats/overview` | 获取统计概览 |
|
|
||||||
|
|
||||||
**PendingServicesController 端点**:
|
|
||||||
|
|
||||||
| 方法 | 路由 | 功能 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/` | 获取待处理服务列表 |
|
|
||||||
| GET | `/{id}` | 获取待处理服务详情 |
|
|
||||||
| POST | `/{id}/assign` | 分配服务到集群 |
|
|
||||||
| POST | `/{id}/reject` | 拒绝服务 |
|
|
||||||
| GET | `/clusters` | 获取可用集群列表 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 Data/ - 数据访问层
|
|
||||||
|
|
||||||
**路径**: `src/Data/`
|
|
||||||
|
|
||||||
**用途**: Entity Framework Core 数据库上下文
|
|
||||||
|
|
||||||
| 文件 | 行数 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `GatewayDbContext.cs` | 142 | EF Core 数据库上下文,包含实体配置和变更通知 |
|
|
||||||
| `GatewayDbContextFactory.cs` | 23 | 设计时 DbContext 工厂,用于迁移命令 |
|
|
||||||
|
|
||||||
**DbContext 特性**:
|
|
||||||
- 自动检测配置变更
|
|
||||||
- 集成 PostgreSQL NOTIFY 机制
|
|
||||||
- 支持软删除(IsDeleted 标记)
|
|
||||||
- 版本号追踪(Version 字段)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.4 DynamicProxy/ - 动态代理层
|
|
||||||
|
|
||||||
**路径**: `src/DynamicProxy/`
|
|
||||||
|
|
||||||
**用途**: YARP 动态配置提供
|
|
||||||
|
|
||||||
| 文件 | 行数 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `DynamicProxyConfigProvider.cs` | 79 | 实现 IProxyConfigProvider,整合路由和集群配置 |
|
|
||||||
|
|
||||||
**核心职责**:
|
|
||||||
- 实现 YARP 配置提供接口
|
|
||||||
- 协调 Route 和 Cluster 配置
|
|
||||||
- 提供配置变更通知(通过 CancellationToken)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.5 LoadBalancing/ - 负载均衡层
|
|
||||||
|
|
||||||
**路径**: `src/LoadBalancing/`
|
|
||||||
|
|
||||||
**用途**: 自定义负载均衡策略
|
|
||||||
|
|
||||||
| 文件 | 行数 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `DistributedWeightedRoundRobinPolicy.cs` | 244 | 基于 Redis 的分布式加权轮询策略 |
|
|
||||||
|
|
||||||
**策略特点**:
|
|
||||||
- 策略名称: `DistributedWeightedRoundRobin`
|
|
||||||
- 支持实例权重配置
|
|
||||||
- Redis 分布式状态存储
|
|
||||||
- 降级策略(锁获取失败时)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.6 Migrations/ - 数据库迁移
|
|
||||||
|
|
||||||
**路径**: `src/Migrations/`
|
|
||||||
|
|
||||||
**用途**: Entity Framework Core 迁移文件
|
|
||||||
|
|
||||||
| 文件 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `20260201120312_InitialCreate.cs` | 初始数据库创建 |
|
|
||||||
| `20260201133826_AddIsGlobalToTenantRoute.cs` | 添加 IsGlobal 字段 |
|
|
||||||
| `20260222134342_AddPendingServiceDiscovery.cs` | 添加待处理服务发现表 |
|
|
||||||
| `*ModelSnapshot.cs` | 当前模型快照 |
|
|
||||||
| `*.Designer.cs` | 设计器生成文件 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.7 Metrics/ - 监控指标
|
|
||||||
|
|
||||||
**路径**: `src/Metrics/`
|
|
||||||
|
|
||||||
**用途**: OpenTelemetry 指标定义
|
|
||||||
|
|
||||||
| 文件 | 行数 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `GatewayMetrics.cs` | 31 | 定义网关监控指标 |
|
|
||||||
|
|
||||||
**指标列表**:
|
|
||||||
- `gateway_requests_total` - 请求总数计数器
|
|
||||||
- `gateway_request_duration_seconds` - 请求延迟直方图
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.8 Middleware/ - 中间件层
|
|
||||||
|
|
||||||
**路径**: `src/Middleware/`
|
|
||||||
|
|
||||||
**用途**: ASP.NET Core 中间件
|
|
||||||
|
|
||||||
| 文件 | 行数 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `JwtTransformMiddleware.cs` | 84 | JWT Token 解析,提取租户信息注入请求头 |
|
|
||||||
| `TenantRoutingMiddleware.cs` | 64 | 租户路由解析,根据路径查找目标集群 |
|
|
||||||
|
|
||||||
**中间件执行顺序**:
|
|
||||||
```
|
|
||||||
CORS -> JwtTransformMiddleware -> TenantRoutingMiddleware -> YARP
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.9 Models/ - 数据模型层
|
|
||||||
|
|
||||||
**路径**: `src/Models/`
|
|
||||||
|
|
||||||
**用途**: 实体类定义
|
|
||||||
|
|
||||||
| 文件 | 行数 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `GwTenant.cs` | 16 | 租户实体 |
|
|
||||||
| `GwTenantRoute.cs` | 20 | 路由配置实体 |
|
|
||||||
| `GwServiceInstance.cs` | 19 | 服务实例实体 |
|
|
||||||
| `GwPendingServiceDiscovery.cs` | 28 | 待处理服务发现实体 + 状态枚举 |
|
|
||||||
|
|
||||||
**实体通用字段**:
|
|
||||||
- `Id` - 主键(雪花 ID 格式)
|
|
||||||
- `Status` - 状态(1=启用)
|
|
||||||
- `CreatedBy/UpdatedBy` - 操作人
|
|
||||||
- `CreatedTime/UpdatedTime` - 时间戳
|
|
||||||
- `IsDeleted` - 软删除标记
|
|
||||||
- `Version` - 版本号(乐观锁)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.10 Services/ - 服务层
|
|
||||||
|
|
||||||
**路径**: `src/Services/`
|
|
||||||
|
|
||||||
**用途**: 业务逻辑和后台服务
|
|
||||||
|
|
||||||
| 文件 | 行数 | 类型 | 用途 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `RouteCache.cs` | 139 | Singleton | 路由缓存,支持租户路由和全局路由 |
|
|
||||||
| `RedisConnectionManager.cs` | 139 | Singleton | Redis 连接管理,分布式锁实现 |
|
|
||||||
| `PgSqlConfigChangeListener.cs` | 223 | HostedService | PostgreSQL 配置变更监听 |
|
|
||||||
| `KubernetesPendingSyncService.cs` | 162 | HostedService | Kubernetes 服务发现同步 |
|
|
||||||
|
|
||||||
**服务生命周期**:
|
|
||||||
- Singleton: RouteCache, RedisConnectionManager(状态服务)
|
|
||||||
- HostedService: PgSqlConfigChangeListener, KubernetesPendingSyncService(后台任务)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 关键文件位置
|
|
||||||
|
|
||||||
### 3.1 入口文件
|
|
||||||
|
|
||||||
| 文件 | 路径 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `Program.cs` | `src/Program.cs` | 应用程序入口,服务注册和中间件配置 |
|
|
||||||
|
|
||||||
### 3.2 配置文件
|
|
||||||
|
|
||||||
| 文件 | 路径 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `appsettings.json` | `src/appsettings.json` | 生产环境配置 |
|
|
||||||
| `appsettings.Development.json` | `src/appsettings.Development.json` | 开发环境配置 |
|
|
||||||
| `YarpGateway.csproj` | `src/YarpGateway.csproj` | 项目文件,包引用 |
|
|
||||||
|
|
||||||
### 3.3 数据库相关
|
|
||||||
|
|
||||||
| 文件 | 路径 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `GatewayDbContext.cs` | `src/Data/GatewayDbContext.cs` | 数据库上下文 |
|
|
||||||
| `GatewayDbContextFactory.cs` | `src/Data/GatewayDbContextFactory.cs` | 迁移工具工厂 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 命名约定
|
|
||||||
|
|
||||||
### 4.1 文件命名
|
|
||||||
|
|
||||||
| 类型 | 命名规则 | 示例 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 实体类 | `Gw` 前缀 + PascalCase | `GwTenant.cs`, `GwTenantRoute.cs` |
|
|
||||||
| 配置类 | `*Config` 后缀 | `JwtConfig.cs`, `RedisConfig.cs` |
|
|
||||||
| 提供者 | `*Provider` 后缀 | `DatabaseRouteConfigProvider.cs` |
|
|
||||||
| 中间件 | `*Middleware` 后缀 | `JwtTransformMiddleware.cs` |
|
|
||||||
| 控制器 | `*Controller` 后缀 | `GatewayConfigController.cs` |
|
|
||||||
| 服务 | 功能描述 + 类型 | `RouteCache.cs`, `PgSqlConfigChangeListener.cs` |
|
|
||||||
| 策略 | `*Policy` 后缀 | `DistributedWeightedRoundRobinPolicy.cs` |
|
|
||||||
|
|
||||||
### 4.2 命名空间
|
|
||||||
|
|
||||||
```
|
|
||||||
YarpGateway # 根命名空间
|
|
||||||
├── Config # 配置相关
|
|
||||||
├── Controllers # API 控制器
|
|
||||||
├── Data # 数据访问
|
|
||||||
├── DynamicProxy # 动态代理
|
|
||||||
├── LoadBalancing # 负载均衡
|
|
||||||
├── Metrics # 监控指标
|
|
||||||
├── Middleware # 中间件
|
|
||||||
├── Models # 数据模型
|
|
||||||
└── Services # 业务服务
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 接口命名
|
|
||||||
|
|
||||||
| 类型 | 命名规则 | 示例 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 服务接口 | `I` 前缀 | `IRouteCache`, `IRedisConnectionManager` |
|
|
||||||
| DTO 类 | `*Dto` 后缀 | `CreateTenantDto`, `CreateRouteDto` |
|
|
||||||
| 请求类 | `*Request` 后缀 | `AssignServiceRequest` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 模块组织
|
|
||||||
|
|
||||||
### 5.1 分层架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Presentation Layer │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
|
|
||||||
│ │ Middleware │ │ Controllers │ │
|
|
||||||
│ │ - JWT 解析 │ │ - GatewayConfigController │ │
|
|
||||||
│ │ - 租户路由 │ │ - PendingServicesController │ │
|
|
||||||
│ └─────────────────┘ └─────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Business Logic Layer │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
|
|
||||||
│ │ Services │ │ DynamicProxy │ │
|
|
||||||
│ │ - RouteCache │ │ - DynamicProxyConfigProvider │ │
|
|
||||||
│ │ - RedisManager │ │ │ │
|
|
||||||
│ │ - ConfigListen │ └─────────────────────────────────┘ │
|
|
||||||
│ └─────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Data Access Layer │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
|
|
||||||
│ │ Models │ │ Data │ │
|
|
||||||
│ │ - GwTenant │ │ - GatewayDbContext │ │
|
|
||||||
│ │ - GwRoute │ │ - GatewayDbContextFactory │ │
|
|
||||||
│ │ - GwInstance │ │ │ │
|
|
||||||
│ └─────────────────┘ └─────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Infrastructure Layer │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
|
|
||||||
│ │ Config │ │ LoadBalancing │ │
|
|
||||||
│ │ - JwtConfig │ │ - WeightedRoundRobinPolicy │ │
|
|
||||||
│ │ - RedisConfig │ │ │ │
|
|
||||||
│ │ - Providers │ └─────────────────────────────────┘ │
|
|
||||||
│ └─────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 模块依赖关系
|
|
||||||
|
|
||||||
```
|
|
||||||
Program.cs
|
|
||||||
│
|
|
||||||
├── Config/
|
|
||||||
│ ├── JwtConfig ◄── appsettings.json
|
|
||||||
│ ├── RedisConfig ◄── appsettings.json
|
|
||||||
│ ├── DatabaseRouteConfigProvider ◄── Data/GatewayDbContext
|
|
||||||
│ └── DatabaseClusterConfigProvider ◄── Data/GatewayDbContext
|
|
||||||
│
|
|
||||||
├── DynamicProxy/
|
|
||||||
│ └── DynamicProxyConfigProvider ◄── Config/*
|
|
||||||
│
|
|
||||||
├── Services/
|
|
||||||
│ ├── RouteCache ◄── Data/GatewayDbContext, Models/*
|
|
||||||
│ ├── RedisConnectionManager ◄── Config/RedisConfig
|
|
||||||
│ ├── PgSqlConfigChangeListener ◄── DynamicProxy, Services/RouteCache
|
|
||||||
│ └── KubernetesPendingSyncService ◄── Data/GatewayDbContext
|
|
||||||
│
|
|
||||||
├── Middleware/
|
|
||||||
│ ├── JwtTransformMiddleware ◄── Config/JwtConfig
|
|
||||||
│ └── TenantRoutingMiddleware ◄── Services/RouteCache
|
|
||||||
│
|
|
||||||
└── Controllers/
|
|
||||||
├── GatewayConfigController ◄── Config/*, Services/RouteCache
|
|
||||||
└── PendingServicesController ◄── Data/GatewayDbContext
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 项目依赖
|
|
||||||
|
|
||||||
### 6.1 NuGet 包引用
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- 核心框架 -->
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
|
||||||
<PackageReference Include="Yarp.ReverseProxy" />
|
|
||||||
|
|
||||||
<!-- 数据库 -->
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
|
||||||
|
|
||||||
<!-- 缓存 -->
|
|
||||||
<PackageReference Include="StackExchange.Redis" />
|
|
||||||
|
|
||||||
<!-- 日志 -->
|
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.Console" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.File" />
|
|
||||||
|
|
||||||
<!-- 服务发现 -->
|
|
||||||
<PackageReference Include="Fengling.ServiceDiscovery.Core" />
|
|
||||||
<PackageReference Include="Fengling.ServiceDiscovery.Kubernetes" />
|
|
||||||
<PackageReference Include="Fengling.ServiceDiscovery.Static" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 目标框架
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 文件统计
|
|
||||||
|
|
||||||
| 目录/文件 | 文件数 | 总行数 | 主要用途 |
|
|
||||||
|-----------|--------|--------|----------|
|
|
||||||
| `Config/` | 5 | ~210 | 配置模型和提供者 |
|
|
||||||
| `Controllers/` | 2 | ~700 | REST API 端点 |
|
|
||||||
| `Data/` | 2 | ~165 | 数据库上下文 |
|
|
||||||
| `DynamicProxy/` | 1 | ~79 | YARP 配置集成 |
|
|
||||||
| `LoadBalancing/` | 1 | ~244 | 负载均衡策略 |
|
|
||||||
| `Migrations/` | 6 | ~500+ | 数据库迁移 |
|
|
||||||
| `Metrics/` | 1 | ~31 | 监控指标 |
|
|
||||||
| `Middleware/` | 2 | ~148 | 请求处理中间件 |
|
|
||||||
| `Models/` | 4 | ~83 | 数据实体 |
|
|
||||||
| `Services/` | 4 | ~665 | 业务服务 |
|
|
||||||
| `Program.cs` | 1 | 135 | 应用入口 |
|
|
||||||
| **总计** | **29** | **~2900+** | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 扩展建议
|
|
||||||
|
|
||||||
### 8.1 建议新增目录
|
|
||||||
|
|
||||||
| 目录 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `Extensions/` | 扩展方法 |
|
|
||||||
| `Constants/` | 常量定义 |
|
|
||||||
| `Exceptions/` | 自定义异常 |
|
|
||||||
| `Validators/` | 输入验证器 |
|
|
||||||
| `Dtos/` | 数据传输对象(从 Controllers 提取) |
|
|
||||||
|
|
||||||
### 8.2 代码组织建议
|
|
||||||
|
|
||||||
1. 将 Controller 中的 DTO 类提取到独立的 `Dtos/` 目录
|
|
||||||
2. 添加 `Extensions/` 存放 IServiceCollection 扩展方法
|
|
||||||
3. 考虑将配置验证逻辑提取到 `Validators/`
|
|
||||||
@ -1,833 +0,0 @@
|
|||||||
# YARP Gateway 测试文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本文档记录了 YARP Gateway 项目的测试策略、测试模式和最佳实践。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 测试框架
|
|
||||||
|
|
||||||
### 1.1 当前测试状态
|
|
||||||
|
|
||||||
**项目当前没有专门的测试目录或测试项目。**
|
|
||||||
|
|
||||||
检查项目结构:
|
|
||||||
```
|
|
||||||
fengling-gateway/
|
|
||||||
├── src/ # 源代码
|
|
||||||
│ └── YarpGateway.csproj # 主项目
|
|
||||||
├── .planning/
|
|
||||||
└── (无 tests/ 或 test/ 目录)
|
|
||||||
```
|
|
||||||
|
|
||||||
检查 `.csproj` 文件确认无测试框架依赖:
|
|
||||||
```xml
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
|
||||||
<PackageReference Include="StackExchange.Redis" />
|
|
||||||
<PackageReference Include="Yarp.ReverseProxy" />
|
|
||||||
</ItemGroup>
|
|
||||||
```
|
|
||||||
|
|
||||||
**结论**:项目目前处于开发阶段,尚未建立测试基础设施。
|
|
||||||
|
|
||||||
### 1.2 推荐测试框架
|
|
||||||
|
|
||||||
基于项目技术栈,推荐以下测试框架:
|
|
||||||
|
|
||||||
| 框架 | 用途 | NuGet 包 |
|
|
||||||
|------|------|----------|
|
|
||||||
| xUnit | 单元测试框架 | `xunit` |
|
|
||||||
| Moq | Mock 框架 | `Moq` |
|
|
||||||
| FluentAssertions | 断言库 | `FluentAssertions` |
|
|
||||||
| Microsoft.NET.Test.Sdk | 测试 SDK | `Microsoft.NET.Test.Sdk` |
|
|
||||||
| Testcontainers | 集成测试容器 | `Testcontainers.PostgreSql`, `Testcontainers.Redis` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 推荐测试结构
|
|
||||||
|
|
||||||
### 2.1 测试项目组织
|
|
||||||
|
|
||||||
建议创建独立的测试项目:
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── YarpGateway.UnitTests/ # 单元测试
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ ├── RouteCacheTests.cs
|
|
||||||
│ │ └── RedisConnectionManagerTests.cs
|
|
||||||
│ ├── Middleware/
|
|
||||||
│ │ ├── JwtTransformMiddlewareTests.cs
|
|
||||||
│ │ └── TenantRoutingMiddlewareTests.cs
|
|
||||||
│ └── Controllers/
|
|
||||||
│ └── GatewayConfigControllerTests.cs
|
|
||||||
│
|
|
||||||
├── YarpGateway.IntegrationTests/ # 集成测试
|
|
||||||
│ ├── GatewayEndpointsTests.cs
|
|
||||||
│ └── DatabaseTests.cs
|
|
||||||
│
|
|
||||||
└── YarpGateway.LoadTests/ # 负载测试(可选)
|
|
||||||
└── RoutePerformanceTests.cs
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 测试命名约定
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 命名格式:[被测类]Tests
|
|
||||||
public class RouteCacheTests { }
|
|
||||||
|
|
||||||
// 方法命名格式:[方法名]_[场景]_[期望结果]
|
|
||||||
[Fact]
|
|
||||||
public async Task InitializeAsync_WithValidData_LoadsRoutesFromDatabase() { }
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetRoute_WithNonexistentTenant_ReturnsNull() { }
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ReloadAsync_WhenCalled_RefreshesCache() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 单元测试模式
|
|
||||||
|
|
||||||
### 3.1 服务层测试示例
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// RouteCacheTests.cs
|
|
||||||
using Xunit;
|
|
||||||
using Moq;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
public class RouteCacheTests
|
|
||||||
{
|
|
||||||
private readonly Mock<IDbContextFactory<GatewayDbContext>> _mockDbContextFactory;
|
|
||||||
private readonly Mock<ILogger<RouteCache>> _mockLogger;
|
|
||||||
private readonly RouteCache _sut; // System Under Test
|
|
||||||
|
|
||||||
public RouteCacheTests()
|
|
||||||
{
|
|
||||||
_mockDbContextFactory = new Mock<IDbContextFactory<GatewayDbContext>>();
|
|
||||||
_mockLogger = new Mock<ILogger<RouteCache>>();
|
|
||||||
_sut = new RouteCache(_mockDbContextFactory.Object, _mockLogger.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InitializeAsync_ShouldLoadRoutesFromDatabase()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var routes = new List<GwTenantRoute>
|
|
||||||
{
|
|
||||||
new() { Id = 1, ServiceName = "user-service", ClusterId = "user-cluster", IsGlobal = true }
|
|
||||||
};
|
|
||||||
|
|
||||||
var mockDbSet = CreateMockDbSet(routes);
|
|
||||||
var mockContext = new Mock<GatewayDbContext>();
|
|
||||||
mockContext.Setup(c => c.TenantRoutes).Returns(mockDbSet.Object);
|
|
||||||
|
|
||||||
_mockDbContextFactory
|
|
||||||
.Setup(f => f.CreateDbContext())
|
|
||||||
.Returns(mockContext.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _sut.InitializeAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var result = _sut.GetRoute("tenant1", "user-service");
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.ClusterId.Should().Be("user-cluster");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetRoute_WhenTenantRouteExists_ReturnsTenantRoute()
|
|
||||||
{
|
|
||||||
// Arrange - 设置租户专用路由
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = _sut.GetRoute("tenant1", "service1");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.IsGlobal.Should().BeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetRoute_WhenNoTenantRouteButGlobalExists_ReturnsGlobalRoute()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = _sut.GetRoute("tenant-without-route", "global-service");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.IsGlobal.Should().BeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助方法:创建模拟 DbSet
|
|
||||||
private Mock<DbSet<T>> CreateMockDbSet<T>(List<T> data) where T : class
|
|
||||||
{
|
|
||||||
var queryable = data.AsQueryable();
|
|
||||||
var mockSet = new Mock<DbSet<T>>();
|
|
||||||
mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
|
|
||||||
mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
|
|
||||||
mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
|
|
||||||
mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(queryable.GetEnumerator());
|
|
||||||
return mockSet;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 中间件测试示例
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// TenantRoutingMiddlewareTests.cs
|
|
||||||
using Xunit;
|
|
||||||
using Moq;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
public class TenantRoutingMiddlewareTests
|
|
||||||
{
|
|
||||||
private readonly Mock<RequestDelegate> _mockNext;
|
|
||||||
private readonly Mock<IRouteCache> _mockRouteCache;
|
|
||||||
private readonly Mock<ILogger<TenantRoutingMiddleware>> _mockLogger;
|
|
||||||
private readonly TenantRoutingMiddleware _sut;
|
|
||||||
|
|
||||||
public TenantRoutingMiddlewareTests()
|
|
||||||
{
|
|
||||||
_mockNext = new Mock<RequestDelegate>();
|
|
||||||
_mockRouteCache = new Mock<IRouteCache>();
|
|
||||||
_mockLogger = new Mock<ILogger<TenantRoutingMiddleware>>();
|
|
||||||
_sut = new TenantRoutingMiddleware(_mockNext.Object, _mockRouteCache.Object, _mockLogger.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InvokeAsync_WithoutTenantHeader_CallsNextWithoutProcessing()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var context = new DefaultHttpContext();
|
|
||||||
context.Request.Path = "/api/user-service/users";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _sut.InvokeAsync(context);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
_mockNext.Verify(n => n(context), Times.Once);
|
|
||||||
_mockRouteCache.Verify(r => r.GetRoute(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InvokeAsync_WithValidTenantAndRoute_SetsDynamicClusterId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var context = new DefaultHttpContext();
|
|
||||||
context.Request.Path = "/api/order-service/orders";
|
|
||||||
context.Request.Headers["X-Tenant-Id"] = "tenant-123";
|
|
||||||
|
|
||||||
var routeInfo = new RouteInfo
|
|
||||||
{
|
|
||||||
ClusterId = "order-cluster",
|
|
||||||
IsGlobal = false
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockRouteCache
|
|
||||||
.Setup(r => r.GetRoute("tenant-123", "order-service"))
|
|
||||||
.Returns(routeInfo);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _sut.InvokeAsync(context);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
context.Items["DynamicClusterId"].Should().Be("order-cluster");
|
|
||||||
_mockNext.Verify(n => n(context), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InvokeAsync_WithNoMatchingRoute_CallsNextWithoutClusterId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var context = new DefaultHttpContext();
|
|
||||||
context.Request.Path = "/api/unknown-service/data";
|
|
||||||
context.Request.Headers["X-Tenant-Id"] = "tenant-123";
|
|
||||||
|
|
||||||
_mockRouteCache
|
|
||||||
.Setup(r => r.GetRoute("tenant-123", "unknown-service"))
|
|
||||||
.Returns((RouteInfo?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await _sut.InvokeAsync(context);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
context.Items.ContainsKey("DynamicClusterId").Should().BeFalse();
|
|
||||||
_mockNext.Verify(n => n(context), Times.Once);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 控制器测试示例
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// GatewayConfigControllerTests.cs
|
|
||||||
using Xunit;
|
|
||||||
using Moq;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
public class GatewayConfigControllerTests
|
|
||||||
{
|
|
||||||
private readonly Mock<IDbContextFactory<GatewayDbContext>> _mockDbFactory;
|
|
||||||
private readonly Mock<DatabaseRouteConfigProvider> _mockRouteProvider;
|
|
||||||
private readonly Mock<DatabaseClusterConfigProvider> _mockClusterProvider;
|
|
||||||
private readonly Mock<IRouteCache> _mockRouteCache;
|
|
||||||
private readonly GatewayConfigController _sut;
|
|
||||||
|
|
||||||
public GatewayConfigControllerTests()
|
|
||||||
{
|
|
||||||
_mockDbFactory = new Mock<IDbContextFactory<GatewayDbContext>>();
|
|
||||||
_mockRouteProvider = new Mock<DatabaseRouteConfigProvider>();
|
|
||||||
_mockClusterProvider = new Mock<DatabaseClusterConfigProvider>();
|
|
||||||
_mockRouteCache = new Mock<IRouteCache>();
|
|
||||||
|
|
||||||
_sut = new GatewayConfigController(
|
|
||||||
_mockDbFactory.Object,
|
|
||||||
_mockRouteProvider.Object,
|
|
||||||
_mockClusterProvider.Object,
|
|
||||||
_mockRouteCache.Object
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetTenants_ShouldReturnPaginatedList()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var tenants = new List<GwTenant>
|
|
||||||
{
|
|
||||||
new() { Id = 1, TenantCode = "tenant1", TenantName = "Tenant 1" },
|
|
||||||
new() { Id = 2, TenantCode = "tenant2", TenantName = "Tenant 2" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设置模拟 DbContext...
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _sut.GetTenants(page: 1, pageSize: 10);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
||||||
var response = okResult.Value.Should().BeAnonymousType();
|
|
||||||
response.Property("total").Should().Be(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateTenant_WithValidData_ReturnsCreatedTenant()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var dto = new GatewayConfigController.CreateTenantDto
|
|
||||||
{
|
|
||||||
TenantCode = "new-tenant",
|
|
||||||
TenantName = "New Tenant"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _sut.CreateTenant(dto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
||||||
okResult.Value.Should().BeAssignableTo<GwTenant>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task DeleteTenant_WithNonexistentId_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
// 设置模拟返回 null
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _sut.DeleteTenant(999);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().BeOfType<NotFoundResult>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Mock 模式
|
|
||||||
|
|
||||||
### 4.1 接口 Mock
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用 Moq 模拟接口
|
|
||||||
public class RouteCacheTests
|
|
||||||
{
|
|
||||||
private readonly Mock<IRouteCache> _mockRouteCache;
|
|
||||||
|
|
||||||
public RouteCacheTests()
|
|
||||||
{
|
|
||||||
_mockRouteCache = new Mock<IRouteCache>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TestMethod()
|
|
||||||
{
|
|
||||||
// 设置返回值
|
|
||||||
_mockRouteCache
|
|
||||||
.Setup(r => r.GetRoute("tenant1", "service1"))
|
|
||||||
.Returns(new RouteInfo { ClusterId = "cluster1" });
|
|
||||||
|
|
||||||
// 设置异步方法
|
|
||||||
_mockRouteCache
|
|
||||||
.Setup(r => r.InitializeAsync())
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
// 验证调用
|
|
||||||
_mockRouteCache.Verify(r => r.GetRoute(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 DbContext Mock
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用 In-Memory 数据库进行测试
|
|
||||||
public class TestDatabaseFixture
|
|
||||||
{
|
|
||||||
public GatewayDbContext CreateContext()
|
|
||||||
{
|
|
||||||
var options = new DbContextOptionsBuilder<GatewayDbContext>()
|
|
||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
|
|
||||||
var context = new GatewayDbContext(options);
|
|
||||||
|
|
||||||
// 种子数据
|
|
||||||
context.Tenants.Add(new GwTenant { Id = 1, TenantCode = "test-tenant" });
|
|
||||||
context.TenantRoutes.Add(new GwTenantRoute
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
ServiceName = "test-service",
|
|
||||||
ClusterId = "test-cluster"
|
|
||||||
});
|
|
||||||
context.SaveChanges();
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GatewayDbContextTests : IClassFixture<TestDatabaseFixture>
|
|
||||||
{
|
|
||||||
private readonly TestDatabaseFixture _fixture;
|
|
||||||
|
|
||||||
public GatewayDbContextTests(TestDatabaseFixture fixture)
|
|
||||||
{
|
|
||||||
_fixture = fixture;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SaveChangesAsync_ShouldNotifyConfigChange()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
await using var context = _fixture.CreateContext();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var route = new GwTenantRoute { ServiceName = "new-service", ClusterId = "new-cluster" };
|
|
||||||
context.TenantRoutes.Add(route);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
// 验证通知行为(如果需要)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 Redis Mock
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用 Moq 模拟 Redis
|
|
||||||
public class RedisConnectionManagerTests
|
|
||||||
{
|
|
||||||
private readonly Mock<IConnectionMultiplexer> _mockRedis;
|
|
||||||
private readonly Mock<IDatabase> _mockDatabase;
|
|
||||||
|
|
||||||
public RedisConnectionManagerTests()
|
|
||||||
{
|
|
||||||
_mockRedis = new Mock<IConnectionMultiplexer>();
|
|
||||||
_mockDatabase = new Mock<IDatabase>();
|
|
||||||
_mockRedis.Setup(r => r.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
|
|
||||||
.Returns(_mockDatabase.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AcquireLockAsync_WhenLockAvailable_ReturnsDisposable()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockDatabase
|
|
||||||
.Setup(d => d.StringSetAsync(
|
|
||||||
It.IsAny<RedisKey>(),
|
|
||||||
It.IsAny<RedisValue>(),
|
|
||||||
It.IsAny<TimeSpan?>(),
|
|
||||||
It.IsAny<When>(),
|
|
||||||
It.IsAny<CommandFlags>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
// 测试逻辑...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 集成测试模式
|
|
||||||
|
|
||||||
### 5.1 WebApplicationFactory 模式
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用 WebApplicationFactory 进行 API 集成测试
|
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
|
|
||||||
public class GatewayIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
|
||||||
{
|
|
||||||
private readonly WebApplicationFactory<Program> _factory;
|
|
||||||
private readonly HttpClient _client;
|
|
||||||
|
|
||||||
public GatewayIntegrationTests(WebApplicationFactory<Program> factory)
|
|
||||||
{
|
|
||||||
_factory = factory.WithWebHostBuilder(builder =>
|
|
||||||
{
|
|
||||||
builder.ConfigureServices(services =>
|
|
||||||
{
|
|
||||||
// 替换真实服务为测试替身
|
|
||||||
services.RemoveAll<IDbContextFactory<GatewayDbContext>>();
|
|
||||||
services.AddDbContextFactory<GatewayDbContext>(options =>
|
|
||||||
options.UseInMemoryDatabase("TestDb"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
_client = _factory.CreateClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetHealth_ReturnsHealthy()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var response = await _client.GetAsync("/health");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
response.Should().BeSuccessful();
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
|
||||||
content.Should().Contain("healthy");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetTenants_ReturnsPaginatedList()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var response = await _client.GetAsync("/api/gateway/tenants?page=1&pageSize=10");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
response.Should().BeSuccessful();
|
|
||||||
// 进一步验证响应内容...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Testcontainers 模式
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 使用 Testcontainers 进行真实数据库集成测试
|
|
||||||
using Testcontainers.PostgreSql;
|
|
||||||
using Testcontainers.Redis;
|
|
||||||
|
|
||||||
public class DatabaseIntegrationTests : IAsyncLifetime
|
|
||||||
{
|
|
||||||
private readonly PostgreSqlContainer _postgresContainer;
|
|
||||||
private readonly RedisContainer _redisContainer;
|
|
||||||
|
|
||||||
public DatabaseIntegrationTests()
|
|
||||||
{
|
|
||||||
_postgresContainer = new PostgreSqlBuilder()
|
|
||||||
.WithImage("postgres:15-alpine")
|
|
||||||
.WithDatabase("test_gateway")
|
|
||||||
.WithUsername("test")
|
|
||||||
.WithPassword("test")
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
_redisContainer = new RedisBuilder()
|
|
||||||
.WithImage("redis:7-alpine")
|
|
||||||
.Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
|
||||||
{
|
|
||||||
await _postgresContainer.StartAsync();
|
|
||||||
await _redisContainer.StartAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DisposeAsync()
|
|
||||||
{
|
|
||||||
await _postgresContainer.DisposeAsync();
|
|
||||||
await _redisContainer.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task FullWorkflow_CreateTenantAndRoute_RouteShouldWork()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var connectionString = _postgresContainer.GetConnectionString();
|
|
||||||
|
|
||||||
// 使用真实连接进行端到端测试...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 测试覆盖率
|
|
||||||
|
|
||||||
### 6.1 当前状态
|
|
||||||
|
|
||||||
项目当前无测试覆盖率数据。
|
|
||||||
|
|
||||||
### 6.2 推荐覆盖率目标
|
|
||||||
|
|
||||||
| 层级 | 目标覆盖率 | 说明 |
|
|
||||||
|------|-----------|------|
|
|
||||||
| Services | 80%+ | 核心业务逻辑,必须高覆盖 |
|
|
||||||
| Middleware | 75%+ | 关键请求处理逻辑 |
|
|
||||||
| Controllers | 70%+ | API 端点行为验证 |
|
|
||||||
| Config | 60%+ | 配置加载和验证 |
|
|
||||||
| Models | 30%+ | 简单 POCO 类,低优先级 |
|
|
||||||
|
|
||||||
### 6.3 配置覆盖率收集
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- 添加到 .csproj 文件 -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行测试并收集覆盖率
|
|
||||||
dotnet test --collect:"XPlat Code Coverage"
|
|
||||||
|
|
||||||
# 生成覆盖率报告
|
|
||||||
dotnet tool install -g dotnet-reportgenerator-globaltool
|
|
||||||
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage-report"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 如何运行测试
|
|
||||||
|
|
||||||
### 7.1 运行所有测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行所有测试
|
|
||||||
dotnet test
|
|
||||||
|
|
||||||
# 运行特定项目
|
|
||||||
dotnet test tests/YarpGateway.UnitTests
|
|
||||||
|
|
||||||
# 运行特定测试类
|
|
||||||
dotnet test --filter "FullyQualifiedName~RouteCacheTests"
|
|
||||||
|
|
||||||
# 运行特定测试方法
|
|
||||||
dotnet test --filter "FullyQualifiedName~RouteCacheTests.InitializeAsync_ShouldLoadRoutesFromDatabase"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 运行测试类别
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 定义测试类别
|
|
||||||
[Trait("Category", "Unit")]
|
|
||||||
public class RouteCacheTests { }
|
|
||||||
|
|
||||||
[Trait("Category", "Integration")]
|
|
||||||
public class GatewayIntegrationTests { }
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 只运行单元测试
|
|
||||||
dotnet test --filter "Category=Unit"
|
|
||||||
|
|
||||||
# 排除集成测试
|
|
||||||
dotnet test --filter "Category!=Integration"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 CI/CD 配置示例
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/test.yml
|
|
||||||
name: Tests
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:15
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: test_gateway
|
|
||||||
POSTGRES_USER: test
|
|
||||||
POSTGRES_PASSWORD: test
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
run: dotnet restore
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: dotnet build --configuration Release --no-restore
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: dotnet test --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage"
|
|
||||||
env:
|
|
||||||
ConnectionStrings__DefaultConnection: "Host=localhost;Database=test_gateway;Username=test;Password=test"
|
|
||||||
Redis__ConnectionString: "localhost:6379"
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
files: ./tests/**/coverage.cobertura.xml
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 测试最佳实践
|
|
||||||
|
|
||||||
### 8.1 AAA 模式
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public async Task Method_Scenario_ExpectedResult()
|
|
||||||
{
|
|
||||||
// Arrange - 准备测试数据和环境
|
|
||||||
var input = "test-data";
|
|
||||||
|
|
||||||
// Act - 执行被测试的方法
|
|
||||||
var result = await _sut.MethodAsync(input);
|
|
||||||
|
|
||||||
// Assert - 验证结果
|
|
||||||
result.Should().Be(expected);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 单一职责
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// ✅ 好:每个测试只验证一个行为
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateTenant_WithValidData_ReturnsCreatedTenant() { }
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateTenant_WithDuplicateCode_ReturnsBadRequest() { }
|
|
||||||
|
|
||||||
// ❌ 差:一个测试验证多个行为
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateTenant_TestsAllScenarios() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 测试隔离
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class RouteCacheTests
|
|
||||||
{
|
|
||||||
// 每个测试使用独立实例
|
|
||||||
private readonly RouteCache _sut;
|
|
||||||
|
|
||||||
public RouteCacheTests()
|
|
||||||
{
|
|
||||||
// 在构造函数中初始化,确保每个测试独立
|
|
||||||
_sut = new RouteCache(...);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4 避免实现细节测试
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// ✅ 好:测试行为而非实现
|
|
||||||
[Fact]
|
|
||||||
public async Task GetRoute_ReturnsCorrectRoute() { }
|
|
||||||
|
|
||||||
// ❌ 差:测试内部实现细节
|
|
||||||
[Fact]
|
|
||||||
public void InternalDictionary_ContainsCorrectKey() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 总结
|
|
||||||
|
|
||||||
### 当前状态
|
|
||||||
- ❌ 无测试项目
|
|
||||||
- ❌ 无测试框架依赖
|
|
||||||
- ❌ 无测试覆盖率
|
|
||||||
- ❌ 无 CI/CD 测试配置
|
|
||||||
|
|
||||||
### 建议行动计划
|
|
||||||
|
|
||||||
1. **创建测试项目**
|
|
||||||
```bash
|
|
||||||
dotnet new xunit -n YarpGateway.UnitTests -o tests/YarpGateway.UnitTests
|
|
||||||
dotnet new xunit -n YarpGateway.IntegrationTests -o tests/YarpGateway.IntegrationTests
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **添加测试依赖**
|
|
||||||
```bash
|
|
||||||
dotnet add package Moq
|
|
||||||
dotnet add package FluentAssertions
|
|
||||||
dotnet add package coverlet.collector
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **优先测试核心服务**
|
|
||||||
- `RouteCache` - 路由缓存核心逻辑
|
|
||||||
- `RedisConnectionManager` - Redis 连接和分布式锁
|
|
||||||
- `TenantRoutingMiddleware` - 租户路由中间件
|
|
||||||
|
|
||||||
4. **建立 CI/CD 测试流程**
|
|
||||||
- 每次提交运行单元测试
|
|
||||||
- 每次合并运行集成测试
|
|
||||||
- 生成覆盖率报告
|
|
||||||
|
|
||||||
通过建立完善的测试体系,可以显著提高代码质量和项目可维护性。
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
# 🧪 YARP 网关测试覆盖计划
|
|
||||||
|
|
||||||
> 分析日期:2026-02-28
|
|
||||||
> 当前状态:**无任何测试代码**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
└── YarpGateway.Tests/
|
|
||||||
├── YarpGateway.Tests.csproj
|
|
||||||
├── Unit/
|
|
||||||
│ ├── Middleware/
|
|
||||||
│ │ ├── JwtTransformMiddlewareTests.cs
|
|
||||||
│ │ └── TenantRoutingMiddlewareTests.cs
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ ├── RouteCacheTests.cs
|
|
||||||
│ │ ├── RedisConnectionManagerTests.cs
|
|
||||||
│ │ └── PgSqlConfigChangeListenerTests.cs
|
|
||||||
│ ├── LoadBalancing/
|
|
||||||
│ │ └── DistributedWeightedRoundRobinPolicyTests.cs
|
|
||||||
│ └── Config/
|
|
||||||
│ ├── DatabaseRouteConfigProviderTests.cs
|
|
||||||
│ └── DatabaseClusterConfigProviderTests.cs
|
|
||||||
├── Integration/
|
|
||||||
│ ├── Controllers/
|
|
||||||
│ │ ├── GatewayConfigControllerTests.cs
|
|
||||||
│ │ └── PendingServicesControllerTests.cs
|
|
||||||
│ └── Middleware/
|
|
||||||
│ └── MiddlewarePipelineTests.cs
|
|
||||||
└── TestHelpers/
|
|
||||||
├── MockDbContext.cs
|
|
||||||
├── MockRedis.cs
|
|
||||||
└── TestFixtures.cs
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0 - 必须覆盖(核心安全)
|
|
||||||
|
|
||||||
### JwtTransformMiddlewareTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldValidateJwtSignature` | 应验证 JWT 签名 | 有效签名的 JWT | 解析成功,Claims 正确 | `IOptions<JwtConfig>` |
|
|
||||||
| `ShouldRejectInvalidToken` | 应拒绝无效 Token | 伪造/过期 JWT | 返回 401 或跳过处理 | - |
|
|
||||||
| `ShouldExtractTenantClaim` | 应正确提取租户 ID | 含 tenant claim 的 JWT | X-Tenant-Id header 设置正确 | - |
|
|
||||||
| `ShouldHandleMissingToken` | 应处理无 Token 请求 | 无 Authorization header | 继续处理(不设置 headers) | - |
|
|
||||||
| `ShouldHandleMalformedToken` | 应处理格式错误 Token | 无效 JWT 格式 | 记录警告,继续处理 | - |
|
|
||||||
|
|
||||||
### TenantRoutingMiddlewareTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldValidateTenantIdAgainstJwt` | 应验证 header 与 JWT 一致 | X-Tenant-Id ≠ JWT tenant | 返回 403 Forbidden | `IRouteCache` |
|
|
||||||
| `ShouldExtractServiceNameFromPath` | 应正确解析服务名 | `/api/user-service/users` | serviceName = "user-service" | - |
|
|
||||||
| `ShouldFindRouteInCache` | 应从缓存找到路由 | 有效租户+服务名 | 设置正确的 clusterId | `IRouteCache` |
|
|
||||||
| `ShouldHandleRouteNotFound` | 应处理路由未找到 | 不存在的服务名 | 记录警告,继续处理 | - |
|
|
||||||
| `ShouldPrioritizeTenantRouteOverGlobal` | 租户路由优先于全局 | 同时存在两种路由 | 使用租户路由 | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1 - 重要覆盖(核心业务)
|
|
||||||
|
|
||||||
### RouteCacheTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldLoadGlobalRoutes` | 应加载全局路由 | 全局路由数据 | `_globalRoutes` 填充 | `IDbContextFactory<GatewayDbContext>` |
|
|
||||||
| `ShouldLoadTenantRoutes` | 应加载租户路由 | 租户路由数据 | `_tenantRoutes` 填充 | - |
|
|
||||||
| `ShouldReturnCorrectRoute` | 应返回正确路由 | 查询请求 | 正确的 `RouteInfo` | - |
|
|
||||||
| `ShouldReturnNullForMissingRoute` | 不存在路由返回 null | 不存在的服务名 | `null` | - |
|
|
||||||
| `ShouldHandleConcurrentReads` | 并发读取应安全 | 多线程读取 | 无异常,数据一致 | - |
|
|
||||||
| `ShouldReloadCorrectly` | 应正确重载 | Reload 调用 | 旧数据清除,新数据加载 | - |
|
|
||||||
|
|
||||||
### RedisConnectionManagerTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldAcquireLock` | 应获取分布式锁 | 有效 key | 锁获取成功 | `IConnectionMultiplexer` |
|
|
||||||
| `ShouldReleaseLockCorrectly` | 应正确释放锁 | 已获取的锁 | 锁释放成功 | - |
|
|
||||||
| `ShouldNotReleaseOthersLock` | 不应释放他人锁 | 其他实例的锁 | 释放失败(安全) | - |
|
|
||||||
| `ShouldHandleConnectionFailure` | 应处理连接失败 | Redis 不可用 | 记录错误,返回失败 | - |
|
|
||||||
| `ShouldExecuteInLock` | 应在锁内执行操作 | 操作委托 | 操作执行,锁正确释放 | - |
|
|
||||||
|
|
||||||
### DistributedWeightedRoundRobinPolicyTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldSelectByWeight` | 应按权重选择 | 权重 [3, 1, 1] | 约 60% 选第一个 | `IConnectionMultiplexer` |
|
|
||||||
| `ShouldFallbackOnLockFailure` | 锁失败应降级 | Redis 不可用 | 降级选择第一个可用 | - |
|
|
||||||
| `ShouldReturnNullWhenNoDestinations` | 无目标返回 null | 空目标列表 | `null` | - |
|
|
||||||
| `ShouldPersistStateToRedis` | 状态应持久化到 Redis | 多次选择 | 状态存储正确 | - |
|
|
||||||
| `ShouldExpireStateAfterTTL` | 状态应在 TTL 后过期 | 1 小时后 | 状态重新初始化 | - |
|
|
||||||
|
|
||||||
### PgSqlConfigChangeListenerTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldListenForNotifications` | 应监听通知 | NOTIFY 事件 | 触发重载 | `NpgsqlConnection` |
|
|
||||||
| `ShouldFallbackToPolling` | 应回退到轮询 | 通知失败 | 定时轮询检测 | - |
|
|
||||||
| `ShouldReconnectOnFailure` | 失败应重连 | 连接断开 | 自动重连 | - |
|
|
||||||
| `ShouldDetectVersionChange` | 应检测版本变化 | 版本号增加 | 触发重载 | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P2 - 推荐覆盖(业务逻辑)
|
|
||||||
|
|
||||||
### GatewayConfigControllerTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldCreateTenant` | 应创建租户 | 有效 DTO | 201 Created | `IDbContextFactory` |
|
|
||||||
| `ShouldRejectDuplicateTenant` | 应拒绝重复租户 | 已存在的 TenantCode | 400 BadRequest | - |
|
|
||||||
| `ShouldCreateRoute` | 应创建路由 | 有效 DTO | 201 Created | - |
|
|
||||||
| `ShouldDeleteTenant` | 应删除租户 | 有效 ID | 204 NoContent | - |
|
|
||||||
| `ShouldReturn404ForMissingTenant` | 不存在租户返回 404 | 无效 ID | 404 NotFound | - |
|
|
||||||
| `ShouldReloadConfig` | 应重载配置 | POST /config/reload | 200 OK | `IRouteCache` |
|
|
||||||
|
|
||||||
### PendingServicesControllerTests
|
|
||||||
|
|
||||||
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|
|
||||||
|---------|------|------|----------|-----------|
|
|
||||||
| `ShouldListPendingServices` | 应列出待处理服务 | GET 请求 | 待处理服务列表 | `IDbContextFactory` |
|
|
||||||
| `ShouldAssignService` | 应分配服务 | 有效请求 | 服务实例创建 | - |
|
|
||||||
| `ShouldRejectInvalidCluster` | 应拒绝无效集群 | 不存在的 ClusterId | 400 BadRequest | - |
|
|
||||||
| `ShouldRejectService` | 应拒绝服务 | reject 请求 | 状态更新为 Rejected | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试依赖
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- YarpGateway.Tests.csproj -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
|
||||||
<PackageReference Include="xunit" Version="2.7.0" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
|
|
||||||
<PackageReference Include="Moq" Version="4.20.70" />
|
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
|
|
||||||
<PackageReference Include="Testcontainers.Redis" Version="3.7.0" />
|
|
||||||
<PackageReference Include="Testcontainers.PostgreSql" Version="3.7.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 运行测试命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行所有测试
|
|
||||||
dotnet test
|
|
||||||
|
|
||||||
# 运行特定测试类
|
|
||||||
dotnet test --filter "FullyQualifiedName~JwtTransformMiddlewareTests"
|
|
||||||
|
|
||||||
# 生成覆盖率报告
|
|
||||||
dotnet test --collect:"XPlat Code Coverage"
|
|
||||||
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 覆盖率目标
|
|
||||||
|
|
||||||
| 组件 | 目标覆盖率 | 优先级 |
|
|
||||||
|------|-----------|--------|
|
|
||||||
| JwtTransformMiddleware | 90% | P0 |
|
|
||||||
| TenantRoutingMiddleware | 85% | P0 |
|
|
||||||
| RouteCache | 80% | P1 |
|
|
||||||
| DistributedWeightedRoundRobinPolicy | 80% | P1 |
|
|
||||||
| Controllers | 70% | P2 |
|
|
||||||
| 整体项目 | 75% | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*测试计划由分析生成,建议按优先级逐步实现。*
|
|
||||||
@ -6,7 +6,4 @@
|
|||||||
<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>
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
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,25 +1,8 @@
|
|||||||
namespace YarpGateway.Config;
|
namespace YarpGateway.Config;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Redis 连接配置
|
|
||||||
/// 注意:ConnectionString 应从环境变量或密钥管理服务获取,不要硬编码凭据
|
|
||||||
/// </summary>
|
|
||||||
public class RedisConfig
|
public class RedisConfig
|
||||||
{
|
{
|
||||||
/// <summary>
|
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
|
||||||
/// 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,4 +1,3 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
@ -10,7 +9,6 @@ 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,4 +1,3 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
@ -8,7 +7,6 @@ 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,14 +6,6 @@ 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;
|
||||||
@ -33,46 +25,32 @@ public class JwtTransformMiddleware
|
|||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
// 安全措施:清除所有 X-* Header 防止 Header 注入攻击
|
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
||||||
var xHeaders = context.Request.Headers
|
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
|
||||||
.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
|
||||||
{
|
{
|
||||||
// 从已验证的 ClaimsPrincipal 中提取信息(安全)
|
var jwtHandler = new JwtSecurityTokenHandler();
|
||||||
var claims = context.User.Claims;
|
var jwtToken = jwtHandler.ReadJwtToken(token);
|
||||||
|
|
||||||
var tenantId = claims.FirstOrDefault(c => c.Type == "tenant")?.Value
|
var tenantId = jwtToken.Claims.FirstOrDefault(c => c.Type == "tenant")?.Value;
|
||||||
?? claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value;
|
var userId = jwtToken
|
||||||
|
.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)
|
||||||
var userId = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value
|
?.Value;
|
||||||
?? claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
var userName = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
|
||||||
|
var roles = jwtToken
|
||||||
var userName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value
|
.Claims.Where(c => c.Type == ClaimTypes.Role)
|
||||||
?? 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))
|
||||||
@ -85,7 +63,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 claims transformed - Tenant: {Tenant}, User: {User}",
|
"JWT transformed - Tenant: {Tenant}, User: {User}",
|
||||||
tenantId,
|
tenantId,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
@ -97,7 +75,7 @@ public class JwtTransformMiddleware
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to extract claims from authenticated user");
|
_logger.LogError(ex, "Failed to parse JWT token");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _next(context);
|
await _next(context);
|
||||||
|
|||||||
@ -1,18 +1,9 @@
|
|||||||
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;
|
||||||
@ -31,37 +22,13 @@ public class TenantRoutingMiddleware
|
|||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var headerTenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
var tenantId = 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);
|
||||||
|
|
||||||
@ -71,10 +38,10 @@ public class TenantRoutingMiddleware
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var route = _routeCache.GetRoute(headerTenantId, serviceName);
|
var route = _routeCache.GetRoute(tenantId, serviceName);
|
||||||
if (route == null)
|
if (route == null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Route not found - Tenant: {Tenant}, Service: {Service}", headerTenantId, serviceName);
|
_logger.LogWarning("Route not found - Tenant: {Tenant}, Service: {Service}", tenantId, serviceName);
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -82,8 +49,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.LogDebug("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
|
_logger.LogInformation("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
|
||||||
headerTenantId, serviceName, route.ClusterId, routeType);
|
tenantId, serviceName, route.ClusterId, routeType);
|
||||||
|
|
||||||
await _next(context);
|
await _next(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@ -24,48 +23,10 @@ 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"))
|
||||||
);
|
);
|
||||||
@ -117,7 +78,6 @@ 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 =>
|
||||||
{
|
{
|
||||||
@ -128,18 +88,16 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
if (allowAnyOrigin)
|
if (allowAnyOrigin)
|
||||||
{
|
{
|
||||||
// AllowAnyOrigin 与 AllowCredentials 不兼容
|
policy.AllowAnyOrigin();
|
||||||
policy.AllowAnyOrigin()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod();
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
policy.WithOrigins(allowedOrigins)
|
policy.WithOrigins(allowedOrigins);
|
||||||
.AllowAnyHeader()
|
}
|
||||||
|
|
||||||
|
policy.AllowAnyHeader()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowCredentials();
|
.AllowCredentials();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -151,14 +109,6 @@ 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,8 +41,7 @@ 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,34 +1,8 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Debug",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Information",
|
"Microsoft.AspNetCore": "Warning"
|
||||||
"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": ""
|
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Authority": "",
|
"Authority": "https://your-auth-server.com",
|
||||||
"Audience": "fengling-gateway",
|
"Audience": "fengling-gateway",
|
||||||
"ValidateIssuer": true,
|
"ValidateIssuer": true,
|
||||||
"ValidateAudience": true
|
"ValidateAudience": true
|
||||||
},
|
},
|
||||||
"Redis": {
|
"Redis": {
|
||||||
"ConnectionString": "",
|
"ConnectionString": "81.68.223.70:6379",
|
||||||
"Database": 0,
|
"Database": 0,
|
||||||
"InstanceName": "YarpGateway"
|
"InstanceName": "YarpGateway"
|
||||||
},
|
},
|
||||||
@ -60,8 +60,5 @@
|
|||||||
"Url": "http://0.0.0.0:8080"
|
"Url": "http://0.0.0.0:8080"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ServiceDiscovery": {
|
|
||||||
"UseInClusterConfig": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,433 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<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