Compare commits

...

7 Commits

Author SHA1 Message Date
movingsam
52f4b7616e docs: add security audit and test plan
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:38:38 +08:00
movingsam
5755b41664 chore: add test project to solution
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:38:21 +08:00
movingsam
d7007d0d7f config: improve Redis configuration and credentials management
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:38:07 +08:00
movingsam
eec65c1e05 security: enhance JWT and tenant routing middleware
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:37:48 +08:00
movingsam
2a4a06ddb8 feat: add JWT authentication to gateway
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:37:31 +08:00
movingsam
0cbb5a2c0e docs: map YARP gateway codebase 2026-02-28 15:44:16 +08:00
movingsam
6ea7f6c958 feat: add k8s deployment manifests and fix docker workflow env
- Add k8s/base/deployment.yaml with Namespace, ConfigMap, Secret, Deployment and Service for YARP gateway
- Fix duplicate REGISTRY/IMAGE_NAME env vars in docker.yml
2026-02-28 15:24:40 +08:00
27 changed files with 5737 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

189
.planning/codebase/STACK.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -6,4 +6,7 @@
<Folder Name="/src/">
<Project Path="src/YarpGateway.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/YarpGateway.Tests/YarpGateway.Tests.csproj" />
</Folder>
</Solution>

140
k8s/base/deployment.yaml Normal file
View File

@ -0,0 +1,140 @@
apiVersion: v1
kind: Namespace
metadata:
name: fengling
labels:
app.kubernetes.io/name: fengling
app.kubernetes.io/managed-by: kubectl
---
apiVersion: v1
kind: ConfigMap
metadata:
name: yarp-gateway-config
namespace: fengling
labels:
app: yarp-gateway
data:
ASPNETCORE_ENVIRONMENT: "Production"
Logging__LogLevel__Default: "Information"
Logging__LogLevel__Microsoft__AspNetCore: "Warning"
Logging__LogLevel__Yarp__ReverseProxy: "Information"
Serilog__MinimumLevel: "Information"
Kestrel__Endpoints__Http__Url: "http://0.0.0.0:8080"
---
apiVersion: v1
kind: Secret
metadata:
name: yarp-gateway-secret
namespace: fengling
labels:
app: yarp-gateway
type: Opaque
stringData:
# PostgreSQL 连接字符串
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=fengling_gateway;Username=movingsam;Password=${POSTGRES_PASSWORD}"
# Redis 连接字符串
Redis__ConnectionString: "redis:6379"
# JWT 配置
Jwt__Authority: "https://your-auth-server.com"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: yarp-gateway
namespace: fengling
labels:
app: yarp-gateway
version: v1
spec:
replicas: 2
selector:
matchLabels:
app: yarp-gateway
template:
metadata:
labels:
app: yarp-gateway
version: v1
spec:
containers:
- name: yarp-gateway
image: gitea.shtao1.cn/fengling/YarpGateway:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: metrics
containerPort: 8081
protocol: TCP
env:
- name: ASPNETCORE_ENVIRONMENT
valueFrom:
configMapKeyRef:
name: yarp-gateway-config
key: ASPNETCORE_ENVIRONMENT
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: yarp-gateway-secret
key: ConnectionStrings__DefaultConnection
- name: Redis__ConnectionString
valueFrom:
secretKeyRef:
name: yarp-gateway-secret
key: Redis__ConnectionString
- name: Jwt__Authority
valueFrom:
secretKeyRef:
name: yarp-gateway-secret
key: Jwt__Authority
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumeMounts:
- name: logs
mountPath: /app/logs
volumes:
- name: logs
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: yarp-gateway
namespace: fengling
labels:
app: yarp-gateway
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
- name: metrics
port: 8081
targetPort: metrics
protocol: TCP
selector:
app: yarp-gateway

View File

@ -1,8 +1,25 @@
namespace YarpGateway.Config;
/// <summary>
/// Redis 连接配置
/// 注意ConnectionString 应从环境变量或密钥管理服务获取,不要硬编码凭据
/// </summary>
public class RedisConfig
{
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
/// <summary>
/// Redis 连接字符串
/// 从环境变量 REDIS_CONNECTION_STRING 读取,或从配置文件获取
/// 格式: host:port,password=xxx
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// Redis 数据库编号
/// </summary>
public int Database { get; set; } = 0;
/// <summary>
/// 实例名称前缀
/// </summary>
public string InstanceName { get; set; } = "YarpGateway";
}
}

View File

@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using YarpGateway.Data;
@ -9,6 +10,7 @@ namespace YarpGateway.Controllers;
[ApiController]
[Route("api/gateway")]
[Authorize] // 要求所有管理 API 都需要认证
public class GatewayConfigController : ControllerBase
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;

View File

@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using YarpGateway.Data;
@ -7,6 +8,7 @@ namespace YarpGateway.Controllers;
[ApiController]
[Route("api/gateway/pending-services")]
[Authorize] // 要求所有管理 API 都需要认证
public class PendingServicesController : ControllerBase
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;

View File

@ -6,6 +6,14 @@ using YarpGateway.Config;
namespace YarpGateway.Middleware;
/// <summary>
/// JWT 转换中间件
///
/// 安全说明:
/// 1. 从已验证的 JWT Claims 中提取用户信息(不是直接解析 token
/// 2. 清除请求中所有 X-* Header 以防止 Header 注入攻击
/// 3. 验证租户 ID 与 JWT 中的 tenant claim 一致
/// </summary>
public class JwtTransformMiddleware
{
private readonly RequestDelegate _next;
@ -25,32 +33,46 @@ public class JwtTransformMiddleware
public async Task InvokeAsync(HttpContext context)
{
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
// 安全措施:清除所有 X-* Header 防止 Header 注入攻击
var xHeaders = context.Request.Headers
.Where(h => h.Key.StartsWith("X-", StringComparison.OrdinalIgnoreCase))
.Select(h => h.Key)
.ToList();
foreach (var header in xHeaders)
{
context.Request.Headers.Remove(header);
}
// 检查用户是否已通过 JWT 认证
if (context.User?.Identity?.IsAuthenticated != true)
{
await _next(context);
return;
}
var token = authHeader.Substring("Bearer ".Length).Trim();
try
{
var jwtHandler = new JwtSecurityTokenHandler();
var jwtToken = jwtHandler.ReadJwtToken(token);
var tenantId = jwtToken.Claims.FirstOrDefault(c => c.Type == "tenant")?.Value;
var userId = jwtToken
.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)
?.Value;
var userName = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
var roles = jwtToken
.Claims.Where(c => c.Type == ClaimTypes.Role)
// 从已验证的 ClaimsPrincipal 中提取信息(安全)
var claims = context.User.Claims;
var tenantId = claims.FirstOrDefault(c => c.Type == "tenant")?.Value
?? claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value;
var userId = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value
?? claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var userName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value
?? claims.FirstOrDefault(c => c.Type == "name")?.Value;
var roles = claims
.Where(c => c.Type == ClaimTypes.Role || c.Type == "role")
.Select(c => c.Value)
.ToList();
if (!string.IsNullOrEmpty(tenantId))
{
// 安全地设置 Header从已验证的 JWT 中提取)
context.Request.Headers["X-Tenant-Id"] = tenantId;
if (!string.IsNullOrEmpty(userId))
@ -63,7 +85,7 @@ public class JwtTransformMiddleware
context.Request.Headers["X-Roles"] = string.Join(",", roles);
_logger.LogInformation(
"JWT transformed - Tenant: {Tenant}, User: {User}",
"JWT claims transformed - Tenant: {Tenant}, User: {User}",
tenantId,
userId
);
@ -75,9 +97,9 @@ public class JwtTransformMiddleware
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse JWT token");
_logger.LogError(ex, "Failed to extract claims from authenticated user");
}
await _next(context);
}
}
}

View File

@ -1,9 +1,18 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using System.Text.RegularExpressions;
using YarpGateway.Services;
namespace YarpGateway.Middleware;
/// <summary>
/// 租户路由中间件
///
/// 安全说明:
/// 1. 验证 X-Tenant-Id Header 与 JWT 中的 tenant claim 一致
/// 2. 防止租户隔离绕过攻击
/// 3. 只有验证通过后才进行路由查找
/// </summary>
public class TenantRoutingMiddleware
{
private readonly RequestDelegate _next;
@ -22,13 +31,37 @@ public class TenantRoutingMiddleware
public async Task InvokeAsync(HttpContext context)
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId))
var headerTenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (string.IsNullOrEmpty(headerTenantId))
{
await _next(context);
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 serviceName = ExtractServiceName(path);
@ -38,10 +71,10 @@ public class TenantRoutingMiddleware
return;
}
var route = _routeCache.GetRoute(tenantId, serviceName);
var route = _routeCache.GetRoute(headerTenantId, serviceName);
if (route == null)
{
_logger.LogWarning("Route not found - Tenant: {Tenant}, Service: {Service}", tenantId, serviceName);
_logger.LogDebug("Route not found - Tenant: {Tenant}, Service: {Service}", headerTenantId, serviceName);
await _next(context);
return;
}
@ -49,8 +82,8 @@ public class TenantRoutingMiddleware
context.Items["DynamicClusterId"] = route.ClusterId;
var routeType = route.IsGlobal ? "global" : "tenant-specific";
_logger.LogInformation("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
tenantId, serviceName, route.ClusterId, routeType);
_logger.LogDebug("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
headerTenantId, serviceName, route.ClusterId, routeType);
await _next(context);
}
@ -60,4 +93,4 @@ public class TenantRoutingMiddleware
var match = Regex.Match(path, @"/api/(\w+)/?");
return match.Success ? match.Groups[1].Value : string.Empty;
}
}
}

View File

@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Serilog;
@ -23,10 +24,48 @@ builder.Host.UseSerilog(
.Enrich.FromLogContext()
);
// 配置 JWT
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);
// 添加 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 =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
);
@ -78,6 +117,7 @@ builder.Services.AddServiceDiscovery();
builder.Services.AddHostedService<KubernetesPendingSyncService>();
// CORS 配置 - 修复 AllowAnyOrigin 与 AllowCredentials 不兼容问题
var corsSettings = builder.Configuration.GetSection("Cors");
builder.Services.AddCors(options =>
{
@ -88,16 +128,18 @@ builder.Services.AddCors(options =>
{
if (allowAnyOrigin)
{
policy.AllowAnyOrigin();
// AllowAnyOrigin 与 AllowCredentials 不兼容
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
}
else
{
policy.WithOrigins(allowedOrigins);
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
@ -109,6 +151,14 @@ builder.Services.AddReverseProxy();
var app = builder.Build();
app.UseCors("AllowFrontend");
// 添加认证和授权中间件(必须在自定义中间件之前)
if (jwtConfig != null && !string.IsNullOrEmpty(jwtConfig.Authority))
{
app.UseAuthentication();
app.UseAuthorization();
}
app.UseMiddleware<JwtTransformMiddleware>();
app.UseMiddleware<TenantRoutingMiddleware>();
@ -131,4 +181,4 @@ catch (Exception ex)
finally
{
Log.CloseAndFlush();
}
}

View File

@ -41,7 +41,8 @@ public class RedisConnectionManager : IRedisConnectionManager
_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;
});
}

View File

@ -1,8 +1,34 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Yarp.ReverseProxy": "Debug"
}
},
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": {
"Authority": "https://your-auth-server.com",
"Audience": "fengling-gateway",
"ValidateIssuer": true,
"ValidateAudience": true
},
"Redis": {
"ConnectionString": "",
"Database": 0,
"InstanceName": "YarpGateway"
},
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174"
],
"AllowAnyOrigin": false
},
"ServiceDiscovery": {
"UseInClusterConfig": false
}
}
}

View File

@ -16,16 +16,16 @@
"AllowAnyOrigin": false
},
"ConnectionStrings": {
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
"DefaultConnection": ""
},
"Jwt": {
"Authority": "https://your-auth-server.com",
"Authority": "",
"Audience": "fengling-gateway",
"ValidateIssuer": true,
"ValidateAudience": true
},
"Redis": {
"ConnectionString": "81.68.223.70:6379",
"ConnectionString": "",
"Database": 0,
"InstanceName": "YarpGateway"
},
@ -60,5 +60,8 @@
"Url": "http://0.0.0.0:8080"
}
}
},
"ServiceDiscovery": {
"UseInClusterConfig": true
}
}
}

View File

@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,21 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Test Packages -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageVersion Include="xunit" Version="2.7.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.7" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
<!-- Centralized from src/ -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.31" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,238 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
using FluentAssertions;
using YarpGateway.Config;
using YarpGateway.Middleware;
namespace YarpGateway.Tests.Unit.Middleware;
public class JwtTransformMiddlewareTests
{
private readonly Mock<ILogger<JwtTransformMiddleware>> _loggerMock;
private readonly JwtConfig _jwtConfig;
public JwtTransformMiddlewareTests()
{
_jwtConfig = new JwtConfig
{
Authority = "https://auth.example.com",
Audience = "yarp-gateway"
};
_loggerMock = new Mock<ILogger<JwtTransformMiddleware>>();
}
private JwtTransformMiddleware CreateMiddleware()
{
var jwtConfigOptions = Options.Create(_jwtConfig);
return new JwtTransformMiddleware(
next: Mock.Of<RequestDelegate>(),
jwtConfig: jwtConfigOptions,
logger: _loggerMock.Object
);
}
private DefaultHttpContext CreateAuthenticatedContext(string? tenantId = "tenant-1", string? userId = "user-1")
{
var context = new DefaultHttpContext();
var claims = new List<Claim>();
if (!string.IsNullOrEmpty(tenantId))
{
claims.Add(new Claim("tenant", tenantId));
}
if (!string.IsNullOrEmpty(userId))
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
claims.Add(new Claim("sub", userId));
}
claims.Add(new Claim(ClaimTypes.Name, "testuser"));
claims.Add(new Claim("name", "Test User"));
claims.Add(new Claim(ClaimTypes.Role, "admin"));
claims.Add(new Claim("role", "user"));
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
context.User = principal;
return context;
}
private DefaultHttpContext CreateUnauthenticatedContext()
{
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal();
return context;
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractTenantClaim()
{
// Arrange
var context = CreateAuthenticatedContext(tenantId: "tenant-123");
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-123");
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractUserId()
{
// Arrange
var context = CreateAuthenticatedContext(userId: "user-456");
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-User-Id"].Should().Contain("user-456");
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractUserName()
{
// Arrange
var context = CreateAuthenticatedContext();
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-User-Name"].Should().Contain("Test User");
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractRoles()
{
// Arrange
var context = CreateAuthenticatedContext();
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-Roles"].Should().Contain("admin,user");
}
[Fact]
public async Task InvokeAsync_WithUnauthenticatedUser_ShouldNotSetHeaders()
{
// Arrange
var context = CreateUnauthenticatedContext();
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers.Should().NotContainKey("X-Tenant-Id");
context.Request.Headers.Should().NotContainKey("X-User-Id");
}
[Fact]
public async Task InvokeAsync_WithMissingTenantClaim_ShouldLogWarning()
{
// Arrange
var context = CreateAuthenticatedContext(tenantId: null!);
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert - should not throw, just log warning
context.Request.Headers.Should().NotContainKey("X-Tenant-Id");
}
[Fact]
public async Task InvokeAsync_WithTenantClaimUsingTenantIdType_ShouldExtractCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var claims = new List<Claim>
{
new Claim("tenant_id", "tenant-using-id-type"),
new Claim(ClaimTypes.NameIdentifier, "user-1")
};
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
context.User = new ClaimsPrincipal(identity);
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-using-id-type");
}
[Fact]
public async Task InvokeAsync_ShouldRemoveExistingXHeaders_PreventHeaderInjection()
{
// Arrange
var context = CreateAuthenticatedContext();
// Simulate header injection attempt
context.Request.Headers["X-Tenant-Id"] = "injected-tenant";
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert - the injected header should be removed and replaced with JWT value
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-1");
}
[Fact]
public async Task InvokeAsync_WithMultipleTenantClaims_ShouldPrioritizeTenantType()
{
// Arrange
var context = new DefaultHttpContext();
var claims = new List<Claim>
{
new Claim("tenant", "tenant-from-claim"),
new Claim("tenant_id", "tenant-id-claim"),
new Claim(ClaimTypes.NameIdentifier, "user-1")
};
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
context.User = new ClaimsPrincipal(identity);
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert - should prioritize "tenant" over "tenant_id"
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-from-claim");
}
[Fact]
public async Task InvokeAsync_WithEmptyClaims_ShouldNotThrow()
{
// Arrange
var context = new DefaultHttpContext();
var identity = new ClaimsIdentity(Array.Empty<Claim>(), JwtBearerDefaults.AuthenticationScheme);
context.User = new ClaimsPrincipal(identity);
var middleware = CreateMiddleware();
// Act & Assert - should not throw
await middleware.InvokeAsync(context);
}
}

View File

@ -0,0 +1,313 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using FluentAssertions;
using YarpGateway.Middleware;
using YarpGateway.Services;
namespace YarpGateway.Tests.Unit.Middleware;
public class TenantRoutingMiddlewareTests
{
private readonly Mock<IRouteCache> _routeCacheMock;
private readonly Mock<ILogger<TenantRoutingMiddleware>> _loggerMock;
private readonly RequestDelegate _nextDelegate;
public TenantRoutingMiddlewareTests()
{
_routeCacheMock = new Mock<IRouteCache>();
_loggerMock = new Mock<ILogger<TenantRoutingMiddleware>>();
// Default: call next
_nextDelegate = _ => Task.CompletedTask;
}
private TenantRoutingMiddleware CreateMiddleware(
IRouteCache? routeCache = null,
RequestDelegate? next = null)
{
return new TenantRoutingMiddleware(
next: next ?? _nextDelegate,
routeCache: routeCache ?? _routeCacheMock.Object,
logger: _loggerMock.Object
);
}
private DefaultHttpContext CreateContext(string? tenantId = null, string path = "/api/user-service/users")
{
var context = new DefaultHttpContext
{
Request = { Path = path }
};
if (!string.IsNullOrEmpty(tenantId))
{
context.Request.Headers["X-Tenant-Id"] = tenantId;
}
return context;
}
private DefaultHttpContext CreateAuthenticatedContext(string tenantId, string headerTenantId)
{
var context = CreateContext(headerTenantId);
var claims = new List<Claim>
{
new Claim("tenant", tenantId),
new Claim(ClaimTypes.NameIdentifier, "user-1")
};
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
context.User = new ClaimsPrincipal(identity);
return context;
}
[Fact]
public async Task InvokeAsync_WithoutTenantHeader_ShouldCallNext()
{
// Arrange
var nextCalled = false;
var middleware = CreateMiddleware(next: _ => { nextCalled = true; return Task.CompletedTask; });
var context = CreateContext(tenantId: null);
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled.Should().BeTrue();
}
[Fact]
public async Task InvokeAsync_WithValidTenantAndRoute_ShouldSetClusterId()
{
// Arrange
var routeInfo = new RouteInfo
{
Id = 1,
ClusterId = "cluster-user-service",
PathPattern = "/api/user-service/**",
Priority = 1,
IsGlobal = false
};
_routeCacheMock
.Setup(x => x.GetRoute("tenant-1", "user-service"))
.Returns(routeInfo);
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1");
// Act
await middleware.InvokeAsync(context);
// Assert
context.Items["DynamicClusterId"].Should().Be("cluster-user-service");
}
[Fact]
public async Task InvokeAsync_WhenRouteNotFound_ShouldCallNext()
{
// Arrange
_routeCacheMock
.Setup(x => x.GetRoute(It.IsAny<string>(), It.IsAny<string>()))
.Returns((RouteInfo?)null);
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1");
// Act
await middleware.InvokeAsync(context);
// Assert - should not throw, just continue
context.Items.Should().NotContainKey("DynamicClusterId");
}
[Fact]
public async Task InvokeAsync_WithTenantIdMismatch_ShouldReturn403()
{
// Arrange
// JWT has tenant-1, but header has tenant-2
var middleware = CreateMiddleware();
var context = CreateAuthenticatedContext("tenant-1", "tenant-2");
// Act
await middleware.InvokeAsync(context);
// Assert
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
[Fact]
public async Task InvokeAsync_WithMatchingTenant_ShouldAllowRequest()
{
// Arrange
var routeInfo = new RouteInfo
{
Id = 1,
ClusterId = "cluster-1",
IsGlobal = false
};
_routeCacheMock
.Setup(x => x.GetRoute("tenant-1", "user-service"))
.Returns(routeInfo);
var middleware = CreateMiddleware();
var context = CreateAuthenticatedContext("tenant-1", "tenant-1");
// Act
await middleware.InvokeAsync(context);
// Assert
context.Response.StatusCode.Should().NotBe(StatusCodes.Status403Forbidden);
context.Items["DynamicClusterId"].Should().Be("cluster-1");
}
[Fact]
public async Task InvokeAsync_WithoutAuthentication_ShouldAllowRequest()
{
// Arrange
var routeInfo = new RouteInfo
{
Id = 1,
ClusterId = "cluster-1",
IsGlobal = false
};
_routeCacheMock
.Setup(x => x.GetRoute("tenant-1", "user-service"))
.Returns(routeInfo);
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1");
// No authentication set
// Act
await middleware.InvokeAsync(context);
// Assert - should proceed without 403
context.Response.StatusCode.Should().NotBe(StatusCodes.Status403Forbidden);
context.Items["DynamicClusterId"].Should().Be("cluster-1");
}
[Theory]
[InlineData("/api/user-service/users", "user-service")]
[InlineData("/api/order-service/orders", "order-service")]
[InlineData("/api/payment/", "payment")]
[InlineData("/api/auth", "auth")]
[InlineData("/other/path", "")]
public async Task InvokeAsync_ShouldExtractServiceNameFromPath(string path, string expectedServiceName)
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1", path: path);
// Act
await middleware.InvokeAsync(context);
// Assert
if (!string.IsNullOrEmpty(expectedServiceName))
{
_routeCacheMock.Verify(
x => x.GetRoute("tenant-1", expectedServiceName),
Times.Once);
}
}
[Fact]
public async Task InvokeAsync_WithTenantRoute_ShouldLogAsTenantSpecific()
{
// Arrange
var routeInfo = new RouteInfo
{
Id = 1,
ClusterId = "cluster-1",
IsGlobal = false
};
_routeCacheMock
.Setup(x => x.GetRoute("tenant-1", "user-service"))
.Returns(routeInfo);
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1");
// Act
await middleware.InvokeAsync(context);
// Assert - just verify it completes without error
context.Items["DynamicClusterId"].Should().Be("cluster-1");
}
[Fact]
public async Task InvokeAsync_WithGlobalRoute_ShouldLogAsGlobal()
{
// Arrange
var routeInfo = new RouteInfo
{
Id = 1,
ClusterId = "global-cluster",
IsGlobal = true
};
_routeCacheMock
.Setup(x => x.GetRoute("tenant-1", "user-service"))
.Returns(routeInfo);
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1");
// Act
await middleware.InvokeAsync(context);
// Assert
context.Items["DynamicClusterId"].Should().Be("global-cluster");
}
[Fact]
public async Task InvokeAsync_WithEmptyPath_ShouldCallNext()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1", path: "");
// Act
await middleware.InvokeAsync(context);
// Assert - should not try to extract service name
_routeCacheMock.Verify(
x => x.GetRoute(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task InvokeAsync_PrioritizesTenantRouteOverGlobal()
{
// Arrange - This test verifies the middleware calls GetRoute with tenant code
// The priority logic is in RouteCache, not in middleware
var tenantRoute = new RouteInfo
{
Id = 1,
ClusterId = "tenant-specific-cluster",
IsGlobal = false
};
_routeCacheMock
.Setup(x => x.GetRoute("tenant-1", "user-service"))
.Returns(tenantRoute);
var middleware = CreateMiddleware();
var context = CreateContext(tenantId: "tenant-1");
// Act
await middleware.InvokeAsync(context);
// Assert
context.Items["DynamicClusterId"].Should().Be("tenant-specific-cluster");
}
}

View File

@ -0,0 +1,303 @@
using Microsoft.Extensions.Logging;
using Moq;
using StackExchange.Redis;
using Xunit;
using FluentAssertions;
using YarpGateway.Config;
using YarpGateway.Services;
namespace YarpGateway.Tests.Unit.Services;
public class RedisConnectionManagerTests
{
private readonly Mock<IConnectionMultiplexer> _connectionMock;
private readonly Mock<IDatabase> _databaseMock;
private readonly Mock<ILogger<RedisConnectionManager>> _loggerMock;
private readonly RedisConfig _config;
public RedisConnectionManagerTests()
{
_connectionMock = new Mock<IConnectionMultiplexer>();
_databaseMock = new Mock<IDatabase>();
_connectionMock
.Setup(x => x.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(_databaseMock.Object);
_loggerMock = new Mock<ILogger<RedisConnectionManager>>();
_config = new RedisConfig
{
ConnectionString = "localhost:6379",
InstanceName = "test-instance",
Database = 0
};
}
private RedisConnectionManager CreateManager(IConnectionMultiplexer? connection = null)
{
var conn = connection ?? _connectionMock.Object;
// Use reflection to create the manager with a mock connection
var manager = new RedisConnectionManager(_config, _loggerMock.Object);
// Replace the lazy connection
var lazyConnectionField = typeof(RedisConnectionManager)
.GetField("_lazyConnection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var lazyConnection = new Lazy<IConnectionMultiplexer>(() => conn);
lazyConnectionField!.SetValue(manager, lazyConnection);
return manager;
}
[Fact]
public void GetConnection_ShouldReturnConnection()
{
// Arrange
var manager = CreateManager();
// Act
var connection = manager.GetConnection();
// Assert
connection.Should().BeSameAs(_connectionMock.Object);
}
[Fact]
public async Task AcquireLockAsync_WhenLockAvailable_ShouldAcquireLock()
{
// Arrange
var db = _databaseMock;
db.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
var manager = CreateManager();
// Act
var lockObj = await manager.AcquireLockAsync("test-key", TimeSpan.FromSeconds(10));
// Assert
lockObj.Should().NotBeNull();
}
[Fact]
public async Task AcquireLockAsync_WhenLockNotAvailable_ShouldRetry()
{
// Arrange
var callCount = 0;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(() =>
{
callCount++;
return callCount > 3; // Succeed after 3 retries
});
var manager = CreateManager();
// Act
var lockObj = await manager.AcquireLockAsync("test-key", TimeSpan.FromSeconds(10));
// Assert
lockObj.Should().NotBeNull();
callCount.Should().BeGreaterThan(1);
}
[Fact]
public async Task AcquireLockAsync_WhenRetryExhausted_ShouldThrowTimeoutException()
{
// Arrange
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(false); // Always fail
var manager = CreateManager();
// Act & Assert
await FluentActions.Invoking(() => manager.AcquireLockAsync("test-key", TimeSpan.FromMilliseconds(100)))
.Should().ThrowAsync<TimeoutException>();
}
[Fact]
public async Task ExecuteInLockAsync_ShouldExecuteFunction()
{
// Arrange
var functionExecuted = false;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
var manager = CreateManager();
// Act
var result = await manager.ExecuteInLockAsync("test-key", () =>
{
functionExecuted = true;
return Task.FromResult("success");
});
// Assert
functionExecuted.Should().BeTrue();
result.Should().Be("success");
}
[Fact]
public async Task ExecuteInLockAsync_ShouldReleaseLockAfterExecution()
{
// Arrange
var lockReleased = false;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
_databaseMock
.Setup(x => x.ScriptEvaluate(
It.IsAny<string>(),
It.IsAny<RedisKey[]>(),
It.IsAny<RedisValue[]>(),
It.IsAny<CommandFlags>()))
.Callback(() => lockReleased = true);
var manager = CreateManager();
// Act
await manager.ExecuteInLockAsync("test-key", () => Task.FromResult("done"));
// Assert
lockReleased.Should().BeTrue();
}
[Fact]
public async Task AcquireLockAsync_ShouldUseCorrectKeyFormat()
{
// Arrange
RedisKey? capturedKey = null;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>((key, _, _, _, _, _) =>
{
capturedKey = key;
})
.Returns(Task.FromResult(true));
var manager = CreateManager();
// Act
await manager.AcquireLockAsync("my-resource");
// Assert
capturedKey.Should().NotBeNull();
capturedKey!.ToString().Should().Contain("lock:test-instance:");
capturedKey.ToString().Should().Contain("my-resource");
}
[Fact]
public async Task AcquireLockAsync_ShouldUseDefaultExpiryWhenNotProvided()
{
// Arrange
TimeSpan? capturedExpiry = null;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>((_, _, expiry, _, _, _) =>
{
capturedExpiry = expiry;
})
.Returns(Task.FromResult(true));
var manager = CreateManager();
// Act
await manager.AcquireLockAsync("test-key");
// Assert
capturedExpiry.Should().NotBeNull();
capturedExpiry.Should().Be(TimeSpan.FromSeconds(10)); // Default is 10 seconds
}
[Fact]
public async Task ExecuteInLockAsync_WithException_ShouldStillReleaseLock()
{
// Arrange
var lockReleased = false;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
_databaseMock
.Setup(x => x.ScriptEvaluate(
It.IsAny<string>(),
It.IsAny<RedisKey[]>(),
It.IsAny<RedisValue[]>(),
It.IsAny<CommandFlags>()))
.Callback(() => lockReleased = true);
var manager = CreateManager();
// Act & Assert
await FluentActions.Invoking(() =>
manager.ExecuteInLockAsync<string>("test-key", () => throw new InvalidOperationException("Test")))
.Should().ThrowAsync<InvalidOperationException>();
await FluentActions.Invoking(() =>
manager.ExecuteInLockAsync<string>("test-key", () => throw new InvalidOperationException("Test")))
.Should().ThrowAsync<InvalidOperationException>();
// Lock should still be released
lockReleased.Should().BeTrue();
}
}

View File

@ -0,0 +1,433 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using FluentAssertions;
using YarpGateway.Data;
using YarpGateway.Models;
using YarpGateway.Services;
namespace YarpGateway.Tests.Unit.Services;
public class RouteCacheTests
{
private readonly Mock<IDbContextFactory<GatewayDbContext>> _dbContextFactoryMock;
private readonly Mock<ILogger<RouteCache>> _loggerMock;
public RouteCacheTests()
{
_dbContextFactoryMock = new Mock<IDbContextFactory<GatewayDbContext>>();
_loggerMock = new Mock<ILogger<RouteCache>>();
}
private GatewayDbContext CreateInMemoryDbContext(List<GwTenantRoute> routes)
{
var options = new DbContextOptionsBuilder<GatewayDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var context = new GatewayDbContext(options);
context.TenantRoutes.AddRange(routes);
context.SaveChanges();
return context;
}
private RouteCache CreateRouteCache(GatewayDbContext context)
{
_dbContextFactoryMock
.Setup(x => x.CreateDbContext())
.Returns(context);
return new RouteCache(
dbContextFactory: _dbContextFactoryMock.Object,
logger: _loggerMock.Object
);
}
[Fact]
public async Task InitializeAsync_ShouldLoadGlobalRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "cluster-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "order-service",
ClusterId = "cluster-order",
PathPattern = "/api/order/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
// Act
await routeCache.InitializeAsync();
// Assert
var result = routeCache.GetRoute("any-tenant", "user-service");
result.Should().NotBeNull();
result!.ClusterId.Should().Be("cluster-user");
}
[Fact]
public async Task InitializeAsync_ShouldLoadTenantRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "tenant-1",
ServiceName = "user-service",
ClusterId = "cluster-tenant-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = false,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
// Act
await routeCache.InitializeAsync();
// Assert
var result = routeCache.GetRoute("tenant-1", "user-service");
result.Should().NotBeNull();
result!.ClusterId.Should().Be("cluster-tenant-user");
}
[Fact]
public async Task GetRoute_WithTenantRouteAvailable_ShouldReturnTenantRoute()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "tenant-1",
ServiceName = "user-service",
ClusterId = "tenant-cluster",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = false,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "global-cluster",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRoute("tenant-1", "user-service");
// Assert - tenant route should be prioritized
result.Should().NotBeNull();
result!.ClusterId.Should().Be("tenant-cluster");
}
[Fact]
public async Task GetRoute_WithoutTenantRoute_ShouldFallbackToGlobal()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "global-cluster",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRoute("unknown-tenant", "user-service");
// Assert
result.Should().NotBeNull();
result!.ClusterId.Should().Be("global-cluster");
}
[Fact]
public async Task GetRoute_WithMissingRoute_ShouldReturnNull()
{
// Arrange
var routes = new List<GwTenantRoute>();
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRoute("tenant-1", "non-existent");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetRouteByPath_WithValidPath_ShouldReturnRoute()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "cluster-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRouteByPath("/api/user/users");
// Assert
result.Should().NotBeNull();
result!.ClusterId.Should().Be("cluster-user");
}
[Fact]
public async Task GetRouteByPath_WithMissingPath_ShouldReturnNull()
{
// Arrange
var routes = new List<GwTenantRoute>();
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRouteByPath("/unknown/path");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ReloadAsync_ShouldClearOldRoutes()
{
// Arrange
var initialRoutes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "old-service",
ClusterId = "old-cluster",
PathPattern = "/api/old/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(initialRoutes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Verify initial state
routeCache.GetRoute("any", "old-service").Should().NotBeNull();
// Modify the database (replace routes)
context.TenantRoutes.RemoveRange(context.TenantRoutes);
context.TenantRoutes.Add(new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "new-service",
ClusterId = "new-cluster",
PathPattern = "/api/new/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
});
context.SaveChanges();
// Act
await routeCache.ReloadAsync();
// Assert - old route should be gone, new route should exist
routeCache.GetRoute("any", "old-service").Should().BeNull();
routeCache.GetRoute("any", "new-service").Should().NotBeNull();
}
[Fact]
public async Task InitializeAsync_ShouldExcludeDeletedRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "active-service",
ClusterId = "cluster-1",
PathPattern = "/api/active/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "deleted-service",
ClusterId = "cluster-2",
PathPattern = "/api/deleted/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = true
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Assert
routeCache.GetRoute("any", "active-service").Should().NotBeNull();
routeCache.GetRoute("any", "deleted-service").Should().BeNull();
}
[Fact]
public async Task InitializeAsync_ShouldExcludeInactiveRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "active-service",
ClusterId = "cluster-1",
PathPattern = "/api/active/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "inactive-service",
ClusterId = "cluster-2",
PathPattern = "/api/inactive/**",
Priority = 1,
Status = 0, // Inactive
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Assert
routeCache.GetRoute("any", "active-service").Should().NotBeNull();
routeCache.GetRoute("any", "inactive-service").Should().BeNull();
}
[Fact]
public async Task GetRoute_ConcurrentReads_ShouldBeThreadSafe()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "cluster-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act & Assert - multiple concurrent reads should not throw
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() => routeCache.GetRoute("any", "user-service")))
.ToList();
var results = await Task.WhenAll(tasks);
// All results should be consistent
results.Should().AllSatisfy(r => r.Should().NotBeNull());
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/YarpGateway.csproj" />
</ItemGroup>
</Project>