chore(auth): upgrade OpenIddict to 7.2.0

This commit is contained in:
Sam 2026-02-01 23:24:47 +08:00
parent 6ca282d208
commit 4ffc256615
219 changed files with 16472 additions and 4 deletions

13
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.2.5",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"deepscan.enable": true
}

View File

@ -0,0 +1,39 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YarpGateway", "src\YarpGateway\YarpGateway.csproj", "{8DDFE39A-06AE-4C02-BA80-27F0C809E959}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Debug|x64.ActiveCfg = Debug|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Debug|x64.Build.0 = Debug|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Debug|x86.ActiveCfg = Debug|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Debug|x86.Build.0 = Debug|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|Any CPU.Build.0 = Release|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x64.ActiveCfg = Release|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x64.Build.0 = Release|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.ActiveCfg = Release|Any CPU
{8DDFE39A-06AE-4C02-BA80-27F0C809E959}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{8DDFE39A-06AE-4C02-BA80-27F0C809E959} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal

288
OpenCode.md Normal file
View File

@ -0,0 +1,288 @@
---
# Fengling Project Rewrite - Conversation Summary
## Project Information
**Project Name**: Fengling (风灵) - QR Code Marketing Management Platform Rewrite
**Project Locations**:
- **Old Project**: `/Users/movingsam/Fengling.Refactory/`
- `Fengling.Backend.Web/` - Old monolithic backend
- `Yarp.Gateway/` - Old gateway (no longer relevant for reference)
- **New Project**: `/Users/movingsam/Fengling.Refactory.Buiding/`
- `src/YarpGateway/` - New independent gateway service
- `src/YarpGateway.Admin/` - Vue3 admin UI
## What We Did
### Phase 1: Initial Gateway Setup
1. Created YARP Gateway backend with:
- YARP 2.2.0 (reverse proxy framework)
- EF Core 9.0.0 + PostgreSQL (192.168.100.10:5432)
- StackExchange.Redis (192.168.100.10:6379) for distributed locks
- Serilog for logging
2. Created Vue3 Admin frontend with:
- Vue 3 + TypeScript + Vite + Element Plus + Pinia
- Running on http://localhost:5173
### Phase 2: Core Architecture Implementation
#### 1. Route Priority Design (99% Global + 1% Tenant-Specific)
**User's requirement**:
> "普通情况租户走特定的实例pod只是特殊场景所以是要对 前缀匹配到不同的服务这块的ui呢"
**Implementation**:
- Added `IsGlobal` boolean field to `GwTenantRoute` table
- Route priority: Tenant-specific routes > Global routes
- Database migration: `20260201133826_AddIsGlobalToTenantRoute`
**Benefit**:
- Before: 100 tenants × 10 services = 1000 route configurations
- After: 10 global routes + few tenant-specific routes
#### 2. In-Memory Route Caching
**File**: `src/YarpGateway/Services/RouteCache.cs`
- Loads routes from database at startup
- Priority-based lookup: tenant route → global route → 404
- Hot reload support via `ReloadAsync()`
- Avoids database queries per request
#### 3. Redis Distributed Load Balancing
**File**: `src/YarpGateway/LoadBalancing/DistributedWeightedRoundRobinPolicy.cs`
- Implements weighted round-robin algorithm
- Uses Redis for distributed locks: `lock:{instanceName}:{clusterId}`
- Stores load balancing state in Redis: `lb:{instanceName}:{clusterId}:state`
- Supports multiple gateway instances
#### 4. Dynamic Proxy Configuration
**File**: `src/YarpGateway/DynamicProxy/DynamicProxyConfigProvider.cs`
- Implements `IProxyConfigProvider`
- Loads routes and clusters from database
- Provides configuration to YARP
#### 5. Tenant Routing Middleware
**File**: `src/YarpGateway/Middleware/TenantRoutingMiddleware.cs`
- Extracts tenant ID from JWT headers (`X-Tenant-Id`)
- Uses `RouteCache` to get route cluster
- Sets `context.Items["DynamicClusterId"]` for YARP
### Phase 3: Frontend Development
Created Vue3 admin pages:
1. **Dashboard.vue** - Statistics dashboard
2. **TenantList.vue** - Tenant management
3. **TenantRoutes.vue** - Tenant-specific routes
4. **GlobalRoutes.vue** - Global routes management (NEW)
5. **ClusterInstances.vue** - Service instance management
### Phase 4: Bug Fixes
1. Fixed 4K screen width constraints (removed max-width, added 100% width/height)
2. Fixed route `/tenants` not displaying until page refresh
3. Fixed CORS configuration (allowed origins: localhost:5173, 127.0.0.1:5173)
4. Fixed multiple compilation errors in `DistributedWeightedRoundRobinPolicy.cs`:
- Missing using statements
- JsonSerializer ambiguity (used full namespace)
- HashCode.Combine signature errors
- Typo: `M.achineName``MachineName`
- Changed `await using var` to `using var`
### Phase 5: Database Configuration
Applied database migration:
```bash
dotnet ef database update
```
## Current Status
### ✅ Completed
1. Backend compiles successfully
2. Frontend runs on http://localhost:5173
3. Database migrations applied
4. Global routes management UI created
5. Redis distributed load balancing implemented
6. In-memory route caching implemented
### ⚠️ Known Issue: YARP Dynamic Routing
**Problem**:
- Accessing `/api/product/test` returns 404
- Logs show: "Request reached end of middleware pipeline"
- `DynamicProxyConfigProvider` is registered but not being used by YARP
**User's comment**:
> "你等下 下游的应用还没建立 肯定404吧"
> "你先把网关的ui更新一下功能呀。这个你先别急着测试吧"
**Conclusion**: The 404 is expected because downstream microservices don't exist yet. User wants to focus on UI updates and move to microservices analysis.
## Database Schema
### Tables
#### Tenants
```sql
Id: bigint (PK)
TenantCode: varchar(50) (unique)
TenantName: varchar(100)
Status: int (1=enabled, 0=disabled)
IsDeleted: boolean
CreatedTime, UpdatedTime, Version
```
#### TenantRoutes
```sql
Id: bigint (PK)
TenantCode: varchar(50) (empty string = global route)
ServiceName: varchar(100)
ClusterId: varchar(100)
PathPattern: varchar(200)
Priority: int (0=global, 10=tenant)
Status: int
IsGlobal: boolean (NEW)
IsDeleted: boolean
CreatedTime, UpdatedTime, Version
```
#### ServiceInstances
```sql
Id: bigint (PK)
ClusterId: varchar(100)
DestinationId: varchar(100)
Address: varchar(200)
Health: int (1=healthy)
Weight: int
Status: int
IsDeleted: boolean
CreatedTime, UpdatedTime, Version
```
## Configuration
### Backend (appsettings.json)
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
},
"Redis": {
"ConnectionString": "192.168.100.10:6379",
"Database": 0,
"InstanceName": "YarpGateway"
},
"Cors": {
"AllowedOrigins": ["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:5174"],
"AllowAnyOrigin": false
},
"ReverseProxy": {
"Routes": {},
"Clusters": {}
}
}
```
### Ports
- Frontend: http://localhost:5173
- Backend: http://0.0.0.0:8080
## API Endpoints
### Tenant Management
- `GET /api/gateway/tenants` - List tenants
- `POST /api/gateway/tenants` - Create tenant
- `DELETE /api/gateway/tenants/{id}` - Delete tenant
### Tenant Routes
- `GET /api/gateway/tenants/{tenantCode}/routes` - List tenant routes
- `POST /api/gateway/tenants/{tenantCode}/routes` - Create tenant route
### Global Routes
- `GET /api/gateway/routes/global` - List global routes
- `POST /api/gateway/routes/global` - Create global route
- `DELETE /api/gateway/routes/{id}` - Delete route
### Cluster Instances
- `GET /api/gateway/clusters/{clusterId}/instances` - List instances
- `POST /api/gateway/clusters/{clusterId}/instances` - Add instance
- `DELETE /api/gateway/instances/{id}` - Delete instance
### Configuration
- `POST /api/gateway/reload` - Reload configuration
## File Structure
```
/Users/movingsam/Fengling.Refactory.Buiding/src/
├── YarpGateway/
│ ├── Config/
│ │ ├── DatabaseRouteConfigProvider.cs
│ │ ├── DatabaseClusterConfigProvider.cs
│ │ ├── JwtConfig.cs
│ │ └── RedisConfig.cs
│ ├── Controllers/
│ │ └── GatewayConfigController.cs
│ ├── Data/
│ │ ├── GatewayDbContext.cs
│ │ └── GatewayDbContextFactory.cs
│ ├── DynamicProxy/
│ │ └── DynamicProxyConfigProvider.cs
│ ├── LoadBalancing/
│ │ └── DistributedWeightedRoundRobinPolicy.cs
│ ├── Middleware/
│ │ ├── JwtTransformMiddleware.cs
│ │ └── TenantRoutingMiddleware.cs
│ ├── Models/
│ │ ├── GwTenant.cs
│ │ ├── GwTenantRoute.cs
│ │ └── GwServiceInstance.cs
│ ├── Services/
│ │ ├── RouteCache.cs
│ │ └── RedisConnectionManager.cs
│ ├── Migrations/
│ │ ├── 20260201120312_InitialCreate.cs
│ │ └── 20260201133826_AddIsGlobalToTenantRoute.cs
│ ├── sql/
│ │ └── init.sql
│ ├── Program.cs
│ └── appsettings.json
└── YarpGateway.Admin/
├── src/
│ ├── api/
│ │ └── index.ts
│ ├── components/
│ │ └── Layout.vue
│ ├── stores/
│ │ └── tenant.ts
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── TenantList.vue
│ │ ├── TenantRoutes.vue
│ │ ├── GlobalRoutes.vue
│ │ └── ClusterInstances.vue
│ ├── router/
│ │ └── index.ts
│ └── main.ts
└── package.json
```
## What We're Doing Now
The user wants to stop working on the gateway routing issue and instead focus on:
1. Updating the gateway UI functionality (COMPLETED - GlobalRoutes.vue added)
2. Analyzing the old backend for microservices split
User's exact words:
> "你先把网关的ui更新一下功能呀。这个你先别急着测试吧"
> "我准备开始分析业务的微服务拆分了"
## Next Steps (For New Conversation)
### Immediate Priority: Microservices Analysis
**Task**: Analyze the old backend (`/Users/movingsam/Fengling.Refactory/Fengling.Backend.Web/`) to determine how to split it into microservices
**Known Old Backend Structure**:
```
/Users/movingsam/Fengling.Refactory/Fengling.Backend.Web/src/src/
├── account/ # Account module
├── activityplan/ # Activity planning
├── basis/ # Basic configuration
├── channel/ # Channel management
├── company/ # Company management
├── coupon/ # Coupon management
├── fieldConfig/ # Field configuration
├── flow/ # Workflow
├── gift/ # Gift management
├── integralConfig/ # Points configuration
├── member/ # Member management
├── promoter/ # Promoter management
├── qipei/ # Service matching
├── reports/ # Reports
├── riskManage/ # Risk management
└── [many more modules...]
```
### Analysis Goals
1. Identify business domain boundaries
2. Determine which modules should become independent microservices
3. Design inter-service communication patterns
4. Plan database splitting strategy
5. Consider shared services (auth, configuration, etc.)
### Deferred Tasks (Lower Priority)
1. Fix YARP `DynamicProxyConfigProvider` to properly integrate with YARP
2. Test dynamic routing with actual downstream services
3. Complete deployment architecture (Docker, Kubernetes)
## Key Technical Decisions
### 1. Why Global Routes + Tenant-Specific Routes?
**Reason**: 99% of tenants share the same services, only 1% need dedicated instances
**Benefit**: Drastically reduces configuration complexity
### 2. Why YARP?
- Microsoft official support
- High performance (based on Kestrel)
- Extensible (custom load balancing policies)
- Dynamic configuration support
### 3. Why Redis?
- Distributed locks for multi-instance scenarios
- Persistent load balancing state
- High performance (millisecond response)
## Important Notes for Continuation
1. **Database Access**: PostgreSQL at 192.168.100.10:5432, Database: fengling_gateway
2. **Redis Access**: 192.168.100.10:6379
3. **Project Location**: `/Users/movingsam/Fengling.Refactory.Buiding/`
4. **User Preference**: Manually handles database migrations (user applies SQL manually)
5. **Old Gateway**: User confirmed the old gateway at `/Users/movingsam/Fengling.Refactory/Yarp.Gateway/` is no longer relevant for reference
---

370
README.md Normal file
View File

@ -0,0 +1,370 @@
# YARP Gateway - 租户路由网关
基于YARP的租户感知API网关支持JWT解析、动态租户路由、加权负载均衡。
## 功能特性
- ✅ JWT解析与Header传递
- ✅ 基于租户的动态路由
- ✅ 加权轮询负载均衡
- ✅ PostgreSQL配置持久化
- ✅ RESTful API管理接口
- ✅ Docker Compose部署
- ✅ Serilog结构化日志
- ✅ Prometheus指标导出
## 架构设计
```
客户端请求 → JWT解析中间件 → 租户路由中间件 → YARP网关 → 后端微服务
```
### 路由流程
1. 客户端携带JWT访问 `/api/product/list`
2. JWT解析中间件提取租户ID`customerA`
3. 添加Header: `X-Tenant-Id: customerA`
4. 租户路由中间件根据路径提取服务名(`product`
5. 动态构造Cluster ID: `customerA-product`
6. YARP将请求转发到该Cluster下的服务实例
7. 加权轮询策略选择实例
## 快速开始
### 前置要求
- .NET 10 SDK
- PostgreSQL 16+
- Docker & Docker Compose可选
### 本地开发
1. **创建数据库**
```bash
psql -h 192.168.100.10 -U postgres -c "CREATE DATABASE fengling_gateway;"
```
2. **执行迁移**
```bash
psql -h 192.168.100.10 -U postgres -d fengling_gateway -f sql/init.sql
```
3. **运行网关**
```bash
cd src/YarpGateway
dotnet run
```
4. **测试请求**
```bash
# 生成测试JWT需要配置Auth服务
# 然后测试请求
curl -H "Authorization: Bearer <JWT_TOKEN>" \
http://localhost:8080/api/product/list
```
### Docker部署
1. **启动所有服务**
```bash
cd docker
docker-compose up -d
```
2. **查看日志**
```bash
docker-compose logs -f gateway
```
3. **停止服务**
```bash
docker-compose down
```
## API接口
### 租户管理
**获取所有租户**
```http
GET /api/gateway/tenants
```
**创建租户**
```http
POST /api/gateway/tenants
Content-Type: application/json
{
"tenantCode": "customerC",
"tenantName": "客户C"
}
```
**删除租户**
```http
DELETE /api/gateway/tenants/{id}
```
### 路由管理
**获取租户路由**
```http
GET /api/gateway/tenants/{tenantCode}/routes
```
**创建路由**
```http
POST /api/gateway/tenants/{tenantCode}/routes
Content-Type: application/json
{
"serviceName": "product",
"pathPattern": "/api/product/{**catch-all}"
}
```
**删除路由**
```http
DELETE /api/gateway/routes/{id}
```
### 服务实例管理
**获取实例列表**
```http
GET /api/gateway/clusters/{clusterId}/instances
```
**添加实例**
```http
POST /api/gateway/clusters/{clusterId}/instances
Content-Type: application/json
{
"destinationId": "product-3",
"address": "http://customerA-product-3:8001",
"weight": 2
}
```
**删除实例**
```http
DELETE /api/gateway/instances/{id}
```
### 配置热更新
```http
POST /api/gateway/reload
```
## JWT格式要求
### 必需Claims
```json
{
"tenant": "customerA",
"sub": "123456",
"unique_name": "张三",
"role": ["admin", "user"]
}
```
### Header转换
JWT解析后以下Header会自动添加到请求中
- `X-Tenant-Id`: 租户ID
- `X-User-Id`: 用户ID
- `X-User-Name`: 用户名
- `X-Roles`: 角色列表(逗号分隔)
## 负载均衡策略
### 加权轮询 (WeightedRoundRobin)
权重高的实例获得更多流量分配。
**配置权重**
```bash
POST /api/gateway/clusters/customerA-product/instances
{
"destinationId": "product-1",
"address": "http://customerA-product-1:8001",
"weight": 10 # 权重10
}
```
**默认权重**: 1
## 数据库表结构
### gw_tenant
租户基础信息表
| 字段 | 类型 | 说明 |
|------|------|------|
| Id | BIGINT | 主键 |
| TenantCode | VARCHAR(50) | 租户编码(唯一) |
| TenantName | VARCHAR(100) | 租户名称 |
| Status | INTEGER | 状态1=启用 0=禁用 |
### gw_tenant_route
租户服务路由配置表
| 字段 | 类型 | 说明 |
|------|------|------|
| Id | BIGINT | 主键 |
| TenantCode | VARCHAR(50) | 租户编码 |
| ServiceName | VARCHAR(100) | 服务名称 |
| ClusterId | VARCHAR(100) | YARP Cluster ID |
| PathPattern | VARCHAR(200) | 路径匹配模式 |
### gw_service_instance
服务实例配置表
| 字段 | 类型 | 说明 |
|------|------|------|
| Id | BIGINT | 主键 |
| ClusterId | VARCHAR(100) | Cluster ID |
| DestinationId | VARCHAR(100) | Destination ID |
| Address | VARCHAR(200) | 服务地址 |
| Weight | INTEGER | 权重 |
| Health | INTEGER | 健康状态1=健康 0=不健康 |
## 监控和日志
### 日志位置
- **控制台**: 实时输出
- **文件**: `logs/gateway-{Date}.log`
### Prometheus指标
默认导出到 `/metrics` 端点(需添加 Prometheus 包)
**可用指标**:
- `gateway_requests_total`: 请求总数(按租户、服务、状态码分组)
- `gateway_request_duration_seconds`: 请求耗时(按租户、服务分组)
## 配置说明
### appsettings.json
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=postgres;Port=5432;Database=fengling_gateway;..."
},
"Jwt": {
"Authority": "https://your-auth-server.com",
"Audience": "fengling-gateway"
},
"ReverseProxy": {
"Routes": { "catch-all-route": { ... } },
"Clusters": { "dynamic-cluster": { ... } }
},
"Serilog": { ... }
}
```
### 环境变量
- `ConnectionStrings__DefaultConnection`: 数据库连接字符串
- `Jwt__Authority`: JWT认证服务器地址
- `Jwt__Audience`: JWT受众
## 性能调优
### 连接池配置
```csharp
services.AddDbContext<GatewayDbContext>(options =>
options.UseNpgsql(connectionString, o =>
{
o.CommandTimeout(30);
o.MaxBatchSize(100);
}));
```
### 日志级别优化
生产环境建议:
```json
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Yarp.ReverseProxy": "Information"
}
}
}
```
## 故障排查
### 常见问题
**1. 路由404**
- 检查 `gw_tenant_route` 表是否有对应路由配置
- 检查JWT中的tenant claim是否正确
**2. 数据库连接失败**
- 验证连接字符串是否正确
- 检查PostgreSQL是否启动
- 检查防火墙设置
**3. 负载均衡不均**
- 检查实例权重配置
- 检查实例健康状态
## 项目结构
```
src/YarpGateway/
├── Config/ # 配置提供者
│ ├── JwtConfig.cs
│ ├── DatabaseRouteConfigProvider.cs
│ └── DatabaseClusterConfigProvider.cs
├── Controllers/ # API控制器
│ └── GatewayConfigController.cs
├── Data/ # 数据库上下文
│ └── GatewayDbContext.cs
├── LoadBalancing/ # 负载均衡策略
│ └── WeightedRoundRobinPolicy.cs
├── Middleware/ # 中间件
│ ├── JwtTransformMiddleware.cs
│ └── TenantRoutingMiddleware.cs
├── Metrics/ # 监控指标
│ └── GatewayMetrics.cs
├── Models/ # 数据模型
│ ├── GwTenant.cs
│ ├── GwTenantRoute.cs
│ └── GwServiceInstance.cs
├── appsettings.json
├── Dockerfile
└── Program.cs
```
## 开发计划
- [ ] 添加Prometheus指标导出
- [ ] 实现Vue3管理界面
- [ ] 添加限流策略
- [ ] 添加熔断机制
- [ ] 实现配置中心集成
- [ ] 添加服务发现集成
## 许可证
MIT License

20
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
version: '3.8'
services:
gateway:
build: ./src/YarpGateway
container_name: fengling-gateway
ports:
- "8080:8080"
environment:
- ConnectionStrings__DefaultConnection=Host=192.168.100.10;Port=5432;Database=fengling_gateway;Username=postgres;Password=postgres
- Jwt__Authority=https://your-auth-server.com
volumes:
- ./logs:/app/logs
networks:
- fengling-network
restart: unless-stopped
networks:
fengling-network:
driver: bridge

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,216 @@
# Fengling Microservices Architecture Design
**Date**: 2025-02-01
**Status**: Approved
## Overview
风灵(Fengling)系统从单体架构重构为微服务架构,采用核心业务微服务拆分策略,每服务独立数据库,通过RabbitMQ异步通信。
## Core Business Services
### 1. 会员服务
- **Database**: `fengling_member`
- **Entities**: Member, MemberLevel, MemberTag, MemberGroup
- **Responsibilities**: 会员信息管理、会员等级、会员分组、标签管理
### 2. 推广员服务
- **Database**: `fengling_promoter`
- **Entities**: Promoter, PromotersActivity, PromoterStore
- **Responsibilities**: 推广员管理、推广员活动、推广员积分
### 3. 营销活动服务
- **Database**: `fengling_activity`
- **Entities**: Activity, ActivityAward, ActivitySign, AfeActivity
- **Responsibilities**: 活动创建、活动报名、签到、抽奖、礼品发放
### 4. 优惠券服务
- **Database**: `fengling_coupon`
- **Entities**: Coupon, CouponExpense, CouponTask
- **Responsibilities**: 优惠券发放、核销、任务配置
### 5. 礼品服务
- **Database**: `fengling_gift`
- **Entities**: Gift, GiftCategory, GiftExpense
- **Responsibilities**: 礼品管理、礼品发放记录
### 6. 订单服务
- **Database**: `fengling_order`
- **Entities**: Order, VirtualOrder, StoreshopOrders
- **Responsibilities**: 订单创建、订单状态管理
### 7. 渠道服务
- **Database**: `fengling_channel`
- **Entities**: Channel, ChannelQrCode, ChannelApply, ChannelTag
- **Responsibilities**: 渠道管理、二维码生成、渠道申请审核
### 8. 门店服务
- **Database**: `fengling_store`
- **Entities**: Store, StoreCategory, StoreLevel, StoreApply
- **Responsibilities**: 门店管理、门店等级、门店申请审核
### 9. 账户服务
- **Database**: `fengling_account`
- **Entities**: Account, CustomerBalance, WalletBalance
- **Responsibilities**: 资金账户、积分账户、钱包余额管理
### 10. 积分服务
- **Database**: `fengling_points`
- **Entities**: Points, IntegralRule, IntegralDetail, PointClearConfig
- **Responsibilities**: 积分规则配置、积分发放/扣减、积分明细
## Infrastructure Services
### 1. 认证授权服务
- **Technology**: OpenIddict (开源免费)
- **Responsibilities**:
- 用户认证(JWT Token签发)
- OAuth2/OIDC标准支持
- 多租户认证(TenantId嵌入Token)
- 权限验证
- **Gateway Integration**: 网关验证Token并传递TenantId到下游服务
### 2. 配置管理
- **Approach**: K8s ConfigMap + appsettings环境变量
- **Shared Library**: `Fengling.Configuration`
- 统一配置读取
- 环境变量覆盖支持
- 无需额外部署
- **Benefits**: 简单可靠,零额外组件
### 3. 日志服务
- **Technology**: Serilog + 云厂商日志服务
- **Collection**:
- 应用输出JSON到stdout
- 云厂商Agent抓取日志
- **Format**: JSON结构化(TraceId, SpanId, TenantId)
### 4. 链路追踪
- **Technology**: OpenTelemetry + Jaeger
- **Scope**: HTTP/RabbitMQ/DB/Redis
- **Retention**: 30天
### 5. 消息队列
- **Technology**: RabbitMQ (集群部署)
- **Exchanges**:
- `activity.exchange`: 营销活动相关消息
- `order.exchange`: 订单相关消息
- `member.exchange`: 会员相关消息
- `points.exchange`: 积分相关消息
- **Persistence**: 开启持久化
- **Dead Letter Queue**: 每个队列配置DLQ
## Communication Pattern
**All services use RabbitMQ for asynchronous communication**
### Message Flows
1. **Order Created**:
- Order Service → `order.exchange` → Points Service (add points)
- Order Service → `order.exchange` → Coupon Service (consume coupon)
2. **Activity Signed**:
- Activity Service → `activity.exchange` → Points Service (add sign points)
- Activity Service → `activity.exchange` → Gift Service (issue gift)
3. **Member Registered**:
- Member Service → `member.exchange` → Channel Service (bind channel)
- Member Service → `member.exchange` → Points Service (init account)
## Database Strategy
**Each microservice has its own PostgreSQL database**
- Naming convention: `fengling_<service_name>`
- No cross-service joins allowed
- Data consistency via eventual consistency (message queue)
- Tenant isolation via `TenantId` column in all tables
## Security
1. **Authentication**: JWT Token via OpenIddict
2. **Authorization**: Role-based access control (RBAC)
3. **Tenant Isolation**: TenantId in JWT + TenantId column in all tables
4. **API Security**: Gateway validates all incoming requests
## Deployment
- **Infrastructure**: Kubernetes
- **Gateway**: YARP Gateway (already implemented)
- **Load Balancing**: Kubernetes Service + Ingress
- **Configuration**: K8s ConfigMap
- **Logging**: Cloud provider log aggregation
- **Monitoring**: Prometheus + Grafana (optional)
## Implementation Priority
### Phase 1: Infrastructure (Current)
1. ✅ YARP Gateway
2. 🔄 Authentication Service (in progress)
3. RabbitMQ Setup
4. OpenTelemetry + Jaeger Setup
### Phase 2: Core Services
5. Member Service
6. Promoter Service
7. Activity Service
8. Order Service
### Phase 3: Supporting Services
9. Coupon Service
10. Gift Service
11. Channel Service
12. Store Service
13. Account Service
14. Points Service
## Technology Stack
- **.NET Version**: .NET 9.0
- **Language**: C# 13
- **Database**: PostgreSQL
- **ORM**: Entity Framework Core 9.0
- **Cache**: Redis (StackExchange.Redis)
- **Message Queue**: RabbitMQ (MassTransit)
- **Authentication**: OpenIddict
- **Logging**: Serilog
- **Tracing**: OpenTelemetry
- **API Gateway**: YARP
- **Container**: Docker
- **Orchestration**: Kubernetes
## Reference Architecture
```
[Client App]
|
v
[YARP Gateway]
|
|---[Tenant Routing]--->
|
[Authentication Service] (OpenIddict)
|
v
[Service Mesh (RabbitMQ)]
|
+---[Member Service]--->[fengling_member DB]
+---[Promoter Service]-->[fengling_promoter DB]
+---[Activity Service]-->[fengling_activity DB]
+---[Order Service]----->[fengling_order DB]
+---[Coupon Service]--->[fengling_coupon DB]
+---[Gift Service]----->[fengling_gift DB]
+---[Channel Service]-->[fengling_channel DB]
+---[Store Service]---->[fengling_store DB]
+---[Account Service]-->[fengling_account DB]
+---[Points Service]--->[fengling_points DB]
```
## Migration Strategy
1. **Phase 1**: Extract shared libraries (Configuration, Logging, Tracing)
2. **Phase 2**: Implement Authentication Service
3. **Phase 3**: Extract services one by one (least dependent first)
4. **Phase 4**: Migrate data from monolithic database
5. **Phase 5**: Update Gateway routing to new services
6. **Phase 6**: Decommission old monolithic application

View File

@ -30,8 +30,8 @@ Edit: `src/Fengling.AuthService/Fengling.AuthService.csproj`
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.AspNetCore" Version="5.0.2" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.0.2" />
<PackageReference Include="OpenIddict.AspNetCore" Version="7.2.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />

398
docs/testing-guide.md Normal file
View File

@ -0,0 +1,398 @@
# YARP Gateway 测试指南
## 1. 数据库准备
### 使用EF Core Migrations初始化数据库
项目使用 EF Core 管理数据库,已生成迁移脚本位于 `sql/init.sql`
```bash
# 连接到PostgreSQL并执行迁移脚本
psql -h 192.168.100.10 -U postgres -d fengling_gateway -f sql/init.sql
```
如果数据库不存在,先创建数据库:
```bash
psql -h 192.168.100.10 -U postgres -c "CREATE DATABASE fengling_gateway;"
```
### 验证数据
```bash
psql -h 192.168.100.10 -U postgres -d fengling_gateway -c "\dt"
```
应该看到3个表
- `Tenants`
- `TenantRoutes`
- `ServiceInstances`
## 2. 启动网关
```bash
cd /Users/movingsam/Fengling.Refactory.Buiding/src/YarpGateway
dotnet run
```
网关将在 `http://localhost:8080` 启动。
## 3. 测试API接口
### 3.1 创建租户
```bash
curl -X POST http://localhost:8080/api/gateway/tenants \
-H "Content-Type: application/json" \
-d '{
"tenantCode": "customerA",
"tenantName": "客户A"
}'
```
响应示例:
```json
{
"id": 1738377721234,
"tenantCode": "customerA",
"tenantName": "客户A",
"status": 1,
"createdBy": null,
"createdTime": "2026-02-01T20:08:41.234Z",
"updatedBy": null,
"updatedTime": null,
"isDeleted": false,
"version": 0
}
```
### 3.2 查看所有租户
```bash
curl http://localhost:8080/api/gateway/tenants
```
### 3.3 为租户创建路由
```bash
curl -X POST http://localhost:8080/api/gateway/tenants/customerA/routes \
-H "Content-Type: application/json" \
-d '{
"serviceName": "product",
"pathPattern": "/api/product/{**catch-all}"
}'
```
响应示例:
```json
{
"id": 1738377722345,
"tenantCode": "customerA",
"serviceName": "product",
"clusterId": "customerA-product",
"pathPattern": "/api/product/{**catch-all}",
"priority": 0,
"status": 1
}
```
### 3.4 查看租户路由
```bash
curl http://localhost:8080/api/gateway/tenants/customerA/routes
```
### 3.5 添加服务实例
```bash
curl -X POST http://localhost:8080/api/gateway/clusters/customerA-product/instances \
-H "Content-Type: application/json" \
-d '{
"destinationId": "product-1",
"address": "http://localhost:8001",
"weight": 1
}'
```
### 3.6 查看Cluster实例
```bash
curl http://localhost:8080/api/gateway/clusters/customerA-product/instances
```
### 3.7 重新加载配置
```bash
curl -X POST http://localhost:8080/api/gateway/reload
```
响应:
```json
{
"message": "Config reloaded successfully"
}
```
## 4. JWT测试
### 4.1 生成测试JWT
使用 https://jwt.io/ 生成测试JWT
**Header**:
```json
{
"alg": "HS256",
"typ": "JWT"
}
```
**Payload**:
```json
{
"tenant": "customerA",
"sub": "123456",
"unique_name": "张三",
"role": ["admin", "user"],
"exp": 1738369200
}
```
**Secret** (示例密钥):
```
your-secret-key-at-least-32-bytes-long
```
### 4.2 测试JWT解析
```bash
JWT_TOKEN="your-generated-jwt-token"
curl -X GET http://localhost:8080/api/product/list \
-H "Authorization: Bearer $JWT_TOKEN"
```
### 4.3 验证Header传递
创建一个测试服务来接收请求:
**创建测试服务** (test-service.js):
```javascript
const http = require('http');
const server = http.createServer((req, res) => {
console.log('收到请求:');
console.log('URL:', req.url);
console.log('Headers:', req.headers);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: '请求成功',
tenantId: req.headers['x-tenant-id'],
userId: req.headers['x-user-id'],
userName: req.headers['x-user-name'],
roles: req.headers['x-roles']
}));
});
server.listen(8001, () => {
console.log('测试服务运行在 http://localhost:8001');
});
```
**启动测试服务**:
```bash
node test-service.js
```
## 5. 负载均衡测试
### 5.1 添加多个服务实例
```bash
# 实例1
curl -X POST http://localhost:8080/api/gateway/clusters/customerA-product/instances \
-H "Content-Type: application/json" \
-d '{
"destinationId": "product-1",
"address": "http://localhost:8001",
"weight": 10
}'
# 实例2
curl -X POST http://localhost:8080/api/gateway/clusters/customerA-product/instances \
-H "Content-Type: application/json" \
-d '{
"destinationId": "product-2",
"address": "http://localhost:8002",
"weight": 5
}'
```
### 5.2 并发测试
```bash
for i in {1..20}; do
curl -X GET http://localhost:8080/api/product/list \
-H "Authorization: Bearer $JWT_TOKEN" &
done
wait
```
观察日志输出,验证请求是否按权重分配到不同实例。
## 6. 日志查看
### 查看网关日志
控制台会输出实时日志,包括:
- JWT解析日志
- 路由选择日志
- 负载均衡选择日志
### 日志文件位置
`/Users/movingsam/Fengling.Refactory.Buiding/src/YarpGateway/logs/gateway-YYYYMMDD.log`
## 7. 故障排查
### 问题1: 无法连接数据库
**测试连接**:
```bash
psql -h 192.168.100.10 -U postgres -d fengling_gateway
```
**检查配置**:
- 确认 `appsettings.json` 中的连接字符串正确
- 确认 PostgreSQL 已启动并可访问
### 问题2: 路由404
**检查租户配置**:
```bash
curl http://localhost:8080/api/gateway/tenants
```
**检查路由配置**:
```bash
curl http://localhost:8080/api/gateway/tenants/customerA/routes
```
**检查实例配置**:
```bash
curl http://localhost:8080/api/gateway/clusters/customerA-product/instances
```
### 问题3: JWT解析失败
**验证JWT格式**:
```bash
echo "your.jwt.token" | cut -d'.' -f2 | base64 -d
```
**检查日志中的错误信息**
### 问题4: 数据库迁移失败
**手动执行SQL脚本**:
```bash
psql -h 192.168.100.10 -U postgres -d fengling_gateway -f sql/init.sql
```
**检查迁移历史**:
```sql
SELECT * FROM "__EFMigrationsHistory";
```
## 8. 使用Docker Compose
如果使用Docker Compose部署
```bash
cd /Users/movingsam/Fengling.Refactory.Buiding/docker
docker-compose up -d
```
查看日志:
```bash
docker-compose logs -f gateway
```
## 9. 清理
### 停止网关
`Ctrl+C` 停止 `dotnet run`
### 删除数据库(如果需要)
```bash
psql -h 192.168.100.10 -U postgres -c "DROP DATABASE fengling_gateway;"
```
## 10. 创建示例数据
### 创建完整示例数据
```bash
# 1. 创建租户
curl -X POST http://localhost:8080/api/gateway/tenants \
-H "Content-Type: application/json" \
-d '{"tenantCode": "customerB", "tenantName": "客户B"}'
# 2. 创建订单服务路由
curl -X POST http://localhost:8080/api/gateway/tenants/customerA/routes \
-H "Content-Type: application/json" \
-d '{"serviceName": "order", "pathPattern": "/api/order/{**catch-all}"}'
# 3. 为客户B创建产品服务
curl -X POST http://localhost:8080/api/gateway/tenants/customerB/routes \
-H "Content-Type: application/json" \
-d '{"serviceName": "product", "pathPattern": "/api/product/{**catch-all}"}'
# 4. 为客户B产品服务添加实例
curl -X POST http://localhost:8080/api/gateway/clusters/customerB-product/instances \
-H "Content-Type: application/json" \
-d '{"destinationId": "product-1", "address": "http://localhost:8003", "weight": 1}'
```
### 验证配置
```bash
# 查看所有租户
curl http://localhost:8080/api/gateway/tenants | jq
# 查看客户A的所有路由
curl http://localhost:8080/api/gateway/tenants/customerA/routes | jq
# 查看客户A产品服务的所有实例
curl http://localhost:8080/api/gateway/clusters/customerA-product/instances | jq
```
## 11. EF Core Migrations管理
### 添加新迁移
```bash
cd /Users/movingsam/Fengling.Refactory.Buiding/src/YarpGateway
dotnet ef migrations add AddNewFeature
```
### 生成SQL脚本
```bash
dotnet ef migrations script --context GatewayDbContext --output ../sql/migration_$(date +%Y%m%d).sql
```
### 应用迁移到数据库
```bash
dotnet ef database update --context GatewayDbContext
```
### 回滚迁移
```bash
dotnet ef database update 20260201120312_InitialCreate --context GatewayDbContext
```

89
sql/init.sql Normal file
View File

@ -0,0 +1,89 @@
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
START TRANSACTION;
CREATE TABLE "ServiceInstances" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"ClusterId" character varying(100) NOT NULL,
"DestinationId" character varying(100) NOT NULL,
"Address" character varying(200) NOT NULL,
"Health" integer NOT NULL,
"Weight" integer NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_ServiceInstances" PRIMARY KEY ("Id")
);
CREATE TABLE "Tenants" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"TenantName" character varying(100) NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_Tenants" PRIMARY KEY ("Id"),
CONSTRAINT "AK_Tenants_TenantCode" UNIQUE ("TenantCode")
);
CREATE TABLE "TenantRoutes" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"ServiceName" character varying(100) NOT NULL,
"ClusterId" character varying(100) NOT NULL,
"PathPattern" character varying(200) NOT NULL,
"Priority" integer NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_TenantRoutes" PRIMARY KEY ("Id"),
CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode" FOREIGN KEY ("TenantCode") REFERENCES "Tenants" ("TenantCode") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX "IX_ServiceInstances_ClusterId_DestinationId" ON "ServiceInstances" ("ClusterId", "DestinationId");
CREATE INDEX "IX_ServiceInstances_Health" ON "ServiceInstances" ("Health");
CREATE INDEX "IX_TenantRoutes_ClusterId" ON "TenantRoutes" ("ClusterId");
CREATE UNIQUE INDEX "IX_TenantRoutes_TenantCode_ServiceName" ON "TenantRoutes" ("TenantCode", "ServiceName");
CREATE UNIQUE INDEX "IX_Tenants_TenantCode" ON "Tenants" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201120312_InitialCreate', '9.0.0');
ALTER TABLE "TenantRoutes" DROP CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode";
ALTER TABLE "Tenants" DROP CONSTRAINT "AK_Tenants_TenantCode";
DROP INDEX "IX_TenantRoutes_TenantCode_ServiceName";
ALTER TABLE "TenantRoutes" ADD "IsGlobal" boolean NOT NULL DEFAULT FALSE;
CREATE INDEX "IX_TenantRoutes_ServiceName" ON "TenantRoutes" ("ServiceName");
CREATE INDEX "IX_TenantRoutes_ServiceName_IsGlobal_Status" ON "TenantRoutes" ("ServiceName", "IsGlobal", "Status");
CREATE INDEX "IX_TenantRoutes_TenantCode" ON "TenantRoutes" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201133826_AddIsGlobalToTenantRoute', '9.0.0');
COMMIT;

View File

@ -6,8 +6,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.AspNetCore" Version="5.0.2" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.0.2" />
<PackageReference Include="OpenIddict.AspNetCore" Version="7.2.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />

View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080

24
src/YarpGateway.Admin/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@ -0,0 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,167 @@
# YARP Gateway Admin
基于Vue3 + Element Plus的YARP网关管理界面。
## 功能特性
- ✅ 租户管理CRUD
- ✅ 租户路由配置
- ✅ 服务实例管理
- ✅ 加权负载均衡配置
- ✅ 配置热重载
- ✅ 实时监控仪表板
- ✅ TypeScript类型支持
## 快速开始
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
访问: http://localhost:5173
### 构建生产版本
```bash
npm run build
```
### 预览生产构建
```bash
npm run preview
```
## 项目结构
```
src/
├── api/ # API接口定义
│ └── index.ts
├── components/ # 公共组件
│ └── Layout.vue
├── router/ # 路由配置
│ └── index.ts
├── stores/ # Pinia状态管理
│ └── tenant.ts
├── views/ # 页面组件
│ ├── TenantList.vue # 租户列表
│ ├── TenantRoutes.vue # 租户路由
│ ├── ClusterInstances.vue # 实例管理
│ └── Dashboard.vue # 仪表板
├── App.vue # 根组件
└── main.ts # 入口文件
```
## 页面说明
### 租户列表 (/tenants)
- 查看所有租户
- 创建新租户
- 删除租户
- 查看租户路由
### 租户路由 (/tenants/:tenantCode/routes)
- 查看租户的所有服务路由
- 创建新路由
- 删除路由
- 查看服务实例
### 服务实例 (/clusters/:clusterId/instances)
- 查看Cluster下的所有实例
- 添加新实例
- 设置权重
- 删除实例
### 监控仪表板 (/dashboard)
- 总租户数
- 总路由数
- 总Cluster数
- 总实例数
- 快速操作
## 环境变量
`.env.development`:
```
VITE_API_BASE_URL=http://localhost:8080
```
## API接口配置
开发环境通过Vite代理转发到后端:
```typescript
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
```
## 技术栈
- **Vue 3** - 渐进式JavaScript框架
- **TypeScript** - 类型安全
- **Vite** - 构建工具
- **Element Plus** - UI组件库
- **Pinia** - 状态管理
- **Vue Router** - 路由管理
- **Axios** - HTTP客户端
## 开发建议
### 添加新页面
1. 在 `src/views/` 创建Vue组件
2. 在 `src/router/index.ts` 添加路由
3. 在 `Layout.vue` 添加菜单项
### 添加API接口
`src/api/index.ts` 添加:
```typescript
export const api = {
yourModule: {
yourMethod: (params) => request.get('/api/your-endpoint', { params })
}
}
```
### 添加状态管理
`src/stores/` 创建Pinia store
```typescript
import { defineStore } from 'pinia'
export const useYourStore = defineStore('your', () => {
// state, actions
})
```
## Docker部署
```bash
# 构建镜像
docker build -t yarp-gateway-admin .
# 运行容器
docker run -d -p 5173:80 yarp-gateway-admin
```
## License
MIT

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yarpgateway-admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://yarp-gateway:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

2560
src/YarpGateway.Admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
{
"name": "yarpgateway-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.4",
"element-plus": "^2.13.2",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^5.0.1"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,27 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
#app {
width: 100%;
height: 100vh;
}
</style>

View File

@ -0,0 +1,92 @@
import axios from 'axios'
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
const request = axios.create({
baseURL,
timeout: 10000
})
request.interceptors.response.use(
response => response.data,
error => {
console.error('API Error:', error)
return Promise.reject(error)
}
)
export interface Tenant {
id: number
tenantCode: string
tenantName: string
status: number
createdBy: number | null
createdTime: string
updatedBy: number | null
updatedTime: string | null
isDeleted: boolean
version: number
}
export interface TenantRoute {
id: number
tenantCode: string
serviceName: string
clusterId: string
pathPattern: string
priority: number
status: number
isGlobal: boolean
createdBy: number | null
createdTime: string
updatedBy: number | null
updatedTime: string | null
isDeleted: boolean
version: number
}
export interface ServiceInstance {
id: number
clusterId: string
destinationId: string
address: string
health: number
weight: number
status: number
createdBy: number | null
createdTime: string
updatedBy: number | null
updatedTime: string | null
isDeleted: boolean
version: number
}
export const api = {
tenants: {
list: () => request.get<Tenant[]>('/api/gateway/tenants'),
create: (data: { tenantCode: string; tenantName: string }) =>
request.post<Tenant>('/api/gateway/tenants', data),
delete: (id: number) => request.delete(`/api/gateway/tenants/${id}`),
getRoutes: (tenantCode: string) =>
request.get<TenantRoute[]>(`/api/gateway/tenants/${tenantCode}/routes`),
createRoute: (tenantCode: string, data: { serviceName: string; pathPattern: string }) =>
request.post<TenantRoute>(`/api/gateway/tenants/${tenantCode}/routes`, data),
deleteRoute: (id: number) => request.delete(`/api/gateway/routes/${id}`)
},
routes: {
listGlobal: () => request.get<TenantRoute[]>('/api/gateway/routes/global'),
createGlobal: (data: { serviceName: string; clusterId: string; pathPattern: string }) =>
request.post<TenantRoute>('/api/gateway/routes/global', data),
delete: (id: number) => request.delete(`/api/gateway/routes/${id}`)
},
clusters: {
getInstances: (clusterId: string) =>
request.get<ServiceInstance[]>(`/api/gateway/clusters/${clusterId}/instances`),
addInstance: (clusterId: string, data: { destinationId: string; address: string; weight: number }) =>
request.post<ServiceInstance>(`/api/gateway/clusters/${clusterId}/instances`, data),
deleteInstance: (id: number) => request.delete(`/api/gateway/instances/${id}`)
},
config: {
reload: () => request.post('/api/gateway/reload')
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<el-container class="layout-container">
<el-aside width="200px">
<div class="logo">YARP Gateway</div>
<el-menu
:default-active="activeMenu"
router
background-color="#001529"
text-color="#fff"
active-text-color="#409eff"
>
<el-menu-item index="/tenants">
<el-icon><User /></el-icon>
<span>租户管理</span>
</el-menu-item>
<el-menu-item index="/routes/global">
<el-icon><Connection /></el-icon>
<span>全局路由</span>
</el-menu-item>
<el-menu-item index="/dashboard">
<el-icon><DataAnalysis /></el-icon>
<span>监控仪表板</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header>
<div class="header-content">
<h2>网关配置管理</h2>
<el-button type="primary" @click="handleReload">
<el-icon><Refresh /></el-icon>
重新加载配置
</el-button>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { User, Connection, DataAnalysis, Refresh } from '@element-plus/icons-vue'
import { api } from '../api'
import { ElMessage } from 'element-plus'
const route = useRoute()
const activeMenu = computed(() => route.path)
async function handleReload() {
try {
await api.config.reload()
ElMessage.success('配置重新加载成功')
} catch (error) {
ElMessage.error('配置重新加载失败')
}
}
</script>
<style scoped>
.layout-container {
width: 100%;
height: 100vh;
display: flex;
}
.el-aside {
background-color: #001529;
overflow-x: hidden;
flex-shrink: 0;
width: 200px;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
color: #fff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.el-menu {
border-right: none;
height: calc(100vh - 60px);
overflow-y: auto;
}
.el-container > .el-container {
flex: 1;
display: flex;
flex-direction: column;
}
.el-header {
background-color: #fff;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 20px;
height: 60px;
width: 100%;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.header-content h2 {
margin: 0;
font-size: 18px;
color: #333;
}
.el-main {
background-color: #f5f5f5;
padding: 20px;
flex: 1;
overflow-y: auto;
width: 100%;
}
</style>

View File

@ -0,0 +1,20 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,45 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Layout',
component: () => import('../components/Layout.vue'),
redirect: '/tenants',
children: [
{
path: 'tenants',
name: 'Tenants',
component: () => import('../views/TenantList.vue')
},
{
path: 'tenants/:tenantCode/routes',
name: 'TenantRoutes',
component: () => import('../views/TenantRoutes.vue')
},
{
path: 'routes/global',
name: 'GlobalRoutes',
component: () => import('../views/GlobalRoutes.vue')
},
{
path: 'clusters/:clusterId/instances',
name: 'ClusterInstances',
component: () => import('../views/ClusterInstances.vue')
},
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue')
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api, type Tenant } from '../api'
export const useTenantStore = defineStore('tenant', () => {
const tenants = ref<Tenant[]>([])
const loading = ref(false)
const tenantMap = computed(() => {
const map = new Map<string, Tenant>()
tenants.value.forEach(t => map.set(t.tenantCode, t))
return map
})
async function loadTenants() {
loading.value = true
try {
tenants.value = await api.tenants.list()
} finally {
loading.value = false
}
}
async function createTenant(data: { tenantCode: string; tenantName: string }) {
const tenant = await api.tenants.create(data)
tenants.value.push(tenant)
return tenant
}
async function deleteTenant(id: number) {
await api.tenants.delete(id)
tenants.value = tenants.value.filter(t => t.id !== id)
}
return {
tenants,
loading,
tenantMap,
loadTenants,
createTenant,
deleteTenant
}
})

View File

@ -0,0 +1,23 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100vh;
}

View File

@ -0,0 +1,145 @@
<template>
<el-card>
<template #header>
<div class="card-header">
<span>{{ clusterId }} - 服务实例</span>
<div>
<el-button @click="backToRoutes">返回路由</el-button>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加实例
</el-button>
</div>
</div>
</template>
<el-table :data="instances" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="destinationId" label="实例ID" width="150" />
<el-table-column prop="address" label="服务地址" width="300" />
<el-table-column prop="weight" label="权重" width="100" />
<el-table-column prop="health" label="健康状态" width="100">
<template #default="{ row }">
<el-tag :type="row.health === 1 ? 'success' : 'danger'">
{{ row.health === 1 ? '健康' : '不健康' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="danger" @click="deleteInstance(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showAddDialog" title="添加实例" width="500px">
<el-form :model="instanceForm" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="实例ID" prop="destinationId">
<el-input v-model="instanceForm.destinationId" placeholder="如: product-1" />
</el-form-item>
<el-form-item label="服务地址" prop="address">
<el-input v-model="instanceForm.address" placeholder="如: http://localhost:8001" />
</el-form-item>
<el-form-item label="权重" prop="weight">
<el-input-number v-model="instanceForm.weight" :min="1" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="addInstance">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { ServiceInstance } from '../api'
const route = useRoute()
const router = useRouter()
const clusterId = ref(route.params.clusterId as string)
const instances = ref<ServiceInstance[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const instanceForm = ref({ destinationId: '', address: '', weight: 1 })
const formRef = ref()
const rules = {
destinationId: [{ required: true, message: '请输入实例ID', trigger: 'blur' }],
address: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
weight: [{ required: true, message: '请输入权重', trigger: 'blur' }]
}
onMounted(() => {
loadInstances()
})
async function loadInstances() {
loading.value = true
try {
instances.value = await api.clusters.getInstances(clusterId.value)
} finally {
loading.value = false
}
}
function backToRoutes() {
const tenantCode = clusterId.value.split('-')[0]
router.push(`/tenants/${tenantCode}/routes`)
}
async function addInstance() {
try {
await formRef.value?.validate()
await api.clusters.addInstance(clusterId.value, instanceForm.value)
ElMessage.success('添加成功')
showAddDialog.value = false
instanceForm.value = { destinationId: '', address: '', weight: 1 }
loadInstances()
} catch (error) {
ElMessage.error('添加失败')
}
}
async function deleteInstance(row: ServiceInstance) {
try {
await ElMessageBox.confirm(`确定要删除实例 ${row.destinationId} 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await api.clusters.deleteInstance(row.id)
ElMessage.success('删除成功')
loadInstances()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
</script>
<style scoped>
.el-card {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -0,0 +1,226 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: #409eff">
<el-icon :size="32"><User /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">总租户数</div>
<div class="stat-value">{{ tenantStore.tenants.length }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: #67c23a">
<el-icon :size="32"><Link /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">全局路由</div>
<div class="stat-value">{{ globalRoutes }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: #e6a23c">
<el-icon :size="32"><Connection /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">Cluster数量</div>
<div class="stat-value">{{ totalClusters }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background: #f56c6c">
<el-icon :size="32"><Monitor /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">实例总数</div>
<div class="stat-value">{{ totalInstances }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>租户分布</span>
</div>
</template>
<el-table :data="tenantStore.tenants" border size="small">
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="tenantName" label="租户名称" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>快速操作</span>
</div>
</template>
<div class="quick-actions">
<el-button type="primary" @click="goToTenants" size="large">
<el-icon><User /></el-icon>
管理租户
</el-button>
<el-button type="success" @click="goToGlobalRoutes" size="large">
<el-icon><Connection /></el-icon>
全局路由
</el-button>
<el-button type="warning" @click="handleReload" size="large">
<el-icon><Refresh /></el-icon>
重载配置
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTenantStore } from '../stores/tenant'
import { api } from '../api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const tenantStore = useTenantStore()
const globalRoutes = ref(0)
const totalClusters = ref(0)
const totalInstances = ref(0)
onMounted(async () => {
await tenantStore.loadTenants()
await loadStats()
})
async function loadStats() {
try {
const globalRoutesData = await api.routes.listGlobal()
globalRoutes.value = globalRoutesData.length
const promises = tenantStore.tenants.map(async tenant => {
const routes = await api.tenants.getRoutes(tenant.tenantCode)
let instanceCount = 0
for (const route of routes) {
const instances = await api.clusters.getInstances(route.clusterId)
instanceCount += instances.length
}
return { routes: routes.length, instances: instanceCount }
})
const results = await Promise.all(promises)
totalInstances.value = results.reduce((sum, r) => sum + r.instances, 0)
totalClusters.value = globalRoutes.value + results.reduce((sum, r) => sum + r.routes, 0)
} catch (error) {
console.error('Failed to load stats:', error)
}
}
function goToTenants() {
router.push('/tenants')
}
function goToGlobalRoutes() {
router.push('/routes/global')
}
async function handleReload() {
try {
await api.config.reload()
ElMessage.success('配置重载成功')
await loadStats()
} catch (error) {
ElMessage.error('配置重载失败')
}
}
</script>
<style scoped>
.dashboard {
width: 100%;
padding: 0;
}
.el-card {
width: 100%;
}
.stat-card {
display: flex;
align-items: center;
gap: 20px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 15px;
}
.quick-actions .el-button {
width: 100%;
height: 50px;
font-size: 16px;
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<div class="global-routes">
<el-card>
<template #header>
<div class="card-header">
<span>全局路由配置</span>
<el-button type="primary" @click="showCreateDialog = true">创建全局路由</el-button>
</div>
</template>
<el-table :data="routes" stripe style="width: 100%">
<el-table-column prop="serviceName" label="服务名称" width="150" />
<el-table-column prop="clusterId" label="集群ID" width="200" />
<el-table-column prop="pathPattern" label="路径模式" />
<el-table-column prop="priority" label="优先级" width="100" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" size="small" @click="viewInstances(row)">查看实例</el-button>
<el-button type="danger" size="small" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showCreateDialog" title="创建全局路由" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="服务名称">
<el-input v-model="form.serviceName" placeholder="例如: product" />
</el-form-item>
<el-form-item label="集群ID">
<el-input v-model="form.clusterId" placeholder="例如: product-cluster" />
</el-form-item>
<el-form-item label="路径模式">
<el-input v-model="form.pathPattern" placeholder="例如: /api/product/{**path}" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreate">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api, type TenantRoute } from '../api'
const router = useRouter()
const routes = ref<TenantRoute[]>([])
const showCreateDialog = ref(false)
const form = ref({
serviceName: '',
clusterId: '',
pathPattern: ''
})
const loadRoutes = async () => {
try {
routes.value = await api.routes.listGlobal()
} catch (error) {
ElMessage.error('加载全局路由失败')
}
}
const handleCreate = async () => {
if (!form.value.serviceName || !form.value.clusterId || !form.value.pathPattern) {
ElMessage.warning('请填写完整信息')
return
}
try {
await api.routes.createGlobal(form.value)
ElMessage.success('创建成功')
showCreateDialog.value = false
form.value = { serviceName: '', clusterId: '', pathPattern: '' }
loadRoutes()
} catch (error) {
ElMessage.error('创建失败')
}
}
const handleDelete = async (id: number) => {
try {
await ElMessageBox.confirm('确定要删除该路由吗?', '提示', { type: 'warning' })
await api.routes.delete(id)
ElMessage.success('删除成功')
loadRoutes()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const viewInstances = (row: TenantRoute) => {
router.push(`/clusters/${row.clusterId}/instances`)
}
onMounted(() => {
loadRoutes()
})
</script>
<style scoped>
.global-routes {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.el-card {
width: 100%;
}
</style>

View File

@ -0,0 +1,120 @@
<template>
<el-card>
<template #header>
<div class="card-header">
<span>租户列表</span>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
新增租户
</el-button>
</div>
</template>
<el-table :data="tenantStore.tenants" v-loading="tenantStore.loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" width="150" />
<el-table-column prop="tenantName" label="租户名称" width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column label="操作" width="300">
<template #default="{ row }">
<el-button link type="primary" @click="viewRoutes(row)">查看路由</el-button>
<el-button link type="primary" @click="editTenant(row)">编辑</el-button>
<el-button link type="danger" @click="deleteTenant(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showCreateDialog" title="新增租户" width="500px">
<el-form :model="tenantForm" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="租户编码" prop="tenantCode">
<el-input v-model="tenantForm.tenantCode" placeholder="如: customerA" />
</el-form-item>
<el-form-item label="租户名称" prop="tenantName">
<el-input v-model="tenantForm.tenantName" placeholder="如: 客户A" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="createTenant">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTenantStore } from '../stores/tenant'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const tenantStore = useTenantStore()
const showCreateDialog = ref(false)
const tenantForm = ref({ tenantCode: '', tenantName: '' })
const formRef = ref()
const rules = {
tenantCode: [{ required: true, message: '请输入租户编码', trigger: 'blur' }],
tenantName: [{ required: true, message: '请输入租户名称', trigger: 'blur' }]
}
onMounted(() => {
tenantStore.loadTenants()
})
function viewRoutes(tenant: any) {
router.push(`/tenants/${tenant.tenantCode}/routes`)
}
function editTenant(tenant: any) {
ElMessage.info('编辑功能开发中')
}
async function createTenant() {
try {
await formRef.value?.validate()
await tenantStore.createTenant(tenantForm.value)
ElMessage.success('创建成功')
showCreateDialog.value = false
tenantForm.value = { tenantCode: '', tenantName: '' }
} catch (error) {
ElMessage.error('创建失败')
}
}
async function deleteTenant(tenant: any) {
try {
await ElMessageBox.confirm(`确定要删除租户 ${tenant.tenantName} 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await tenantStore.deleteTenant(tenant.id)
ElMessage.success('删除成功')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
</script>
<style scoped>
.el-card {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -0,0 +1,139 @@
<template>
<el-card>
<template #header>
<div class="card-header">
<span>{{ tenantCode }} - 服务路由</span>
<div>
<el-button @click="backToTenants">返回</el-button>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
新增路由
</el-button>
</div>
</div>
</template>
<el-table :data="routes" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="serviceName" label="服务名称" width="150" />
<el-table-column prop="clusterId" label="Cluster ID" width="200" />
<el-table-column prop="pathPattern" label="路径模式" width="300" />
<el-table-column prop="priority" label="优先级" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link type="primary" @click="viewInstances(row)">查看实例</el-button>
<el-button link type="danger" @click="deleteRoute(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showCreateDialog" title="新增路由" width="500px">
<el-form :model="routeForm" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="服务名称" prop="serviceName">
<el-input v-model="routeForm.serviceName" placeholder="如: product" />
</el-form-item>
<el-form-item label="路径模式" prop="pathPattern">
<el-input v-model="routeForm.pathPattern" placeholder="如: /api/product/{**catch-all}" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="createRoute">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { TenantRoute } from '../api'
const route = useRoute()
const router = useRouter()
const tenantCode = ref(route.params.tenantCode as string)
const routes = ref<TenantRoute[]>([])
const loading = ref(false)
const showCreateDialog = ref(false)
const routeForm = ref({ serviceName: '', pathPattern: '' })
const formRef = ref()
const rules = {
serviceName: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
pathPattern: [{ required: true, message: '请输入路径模式', trigger: 'blur' }]
}
onMounted(() => {
loadRoutes()
})
async function loadRoutes() {
loading.value = true
try {
routes.value = await api.tenants.getRoutes(tenantCode.value)
} finally {
loading.value = false
}
}
function backToTenants() {
router.push('/tenants')
}
function viewInstances(row: TenantRoute) {
router.push(`/clusters/${row.clusterId}/instances`)
}
async function createRoute() {
try {
await formRef.value?.validate()
await api.tenants.createRoute(tenantCode.value, routeForm.value)
ElMessage.success('创建成功')
showCreateDialog.value = false
routeForm.value = { serviceName: '', pathPattern: '' }
loadRoutes()
} catch (error) {
ElMessage.error('创建失败')
}
}
async function deleteRoute(row: TenantRoute) {
try {
await ElMessageBox.confirm(`确定要删除路由 ${row.serviceName} 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await api.tenants.deleteRoute(row.id)
ElMessage.success('删除成功')
loadRoutes()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
</script>
<style scoped>
.el-card {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})

View File

@ -0,0 +1,99 @@
using Yarp.ReverseProxy.Configuration;
using Microsoft.EntityFrameworkCore;
using System.Collections.Concurrent;
using YarpGateway.Data;
using YarpGateway.Models;
namespace YarpGateway.Config;
public class DatabaseClusterConfigProvider
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly ConcurrentDictionary<string, ClusterConfig> _clusters = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly ILogger<DatabaseClusterConfigProvider> _logger;
public DatabaseClusterConfigProvider(IDbContextFactory<GatewayDbContext> dbContextFactory, ILogger<DatabaseClusterConfigProvider> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
_ = LoadConfigAsync();
}
public IReadOnlyList<ClusterConfig> GetClusters()
{
return _clusters.Values.ToList().AsReadOnly();
}
public async Task ReloadAsync()
{
await _lock.WaitAsync();
try
{
await LoadConfigInternalAsync();
}
finally
{
_lock.Release();
}
}
private async Task LoadConfigAsync()
{
await LoadConfigInternalAsync();
}
private async Task LoadConfigInternalAsync()
{
await using var dbContext = _dbContextFactory.CreateDbContext();
var instances = await dbContext.ServiceInstances
.Where(i => i.Status == 1 && !i.IsDeleted)
.GroupBy(i => i.ClusterId)
.ToListAsync();
var newClusters = new ConcurrentDictionary<string, ClusterConfig>();
foreach (var group in instances)
{
var destinations = new Dictionary<string, DestinationConfig>();
foreach (var instance in group)
{
destinations[instance.DestinationId] = new DestinationConfig
{
Address = instance.Address,
Metadata = new Dictionary<string, string>
{
["Weight"] = instance.Weight.ToString()
}
};
}
var config = new ClusterConfig
{
ClusterId = group.Key,
Destinations = destinations,
LoadBalancingPolicy = "DistributedWeightedRoundRobin",
HealthCheck = new HealthCheckConfig
{
Active = new ActiveHealthCheckConfig
{
Enabled = true,
Interval = TimeSpan.FromSeconds(30),
Timeout = TimeSpan.FromSeconds(5),
Path = "/health"
}
}
};
newClusters[group.Key] = config;
}
_clusters.Clear();
foreach (var cluster in newClusters)
{
_clusters[cluster.Key] = cluster.Value;
}
_logger.LogInformation("Loaded {Count} clusters from database", _clusters.Count);
}
}

View File

@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using Microsoft.EntityFrameworkCore;
using Yarp.ReverseProxy.Configuration;
using YarpGateway.Data;
using YarpGateway.Models;
namespace YarpGateway.Config;
public class DatabaseRouteConfigProvider
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly ConcurrentDictionary<string, RouteConfig> _routes = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly ILogger<DatabaseRouteConfigProvider> _logger;
public DatabaseRouteConfigProvider(
IDbContextFactory<GatewayDbContext> dbContextFactory,
ILogger<DatabaseRouteConfigProvider> logger
)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
_ = LoadConfigAsync();
}
public IReadOnlyList<RouteConfig> GetRoutes()
{
return _routes.Values.ToList().AsReadOnly();
}
public async Task ReloadAsync()
{
await _lock.WaitAsync();
try
{
await LoadConfigInternalAsync();
}
finally
{
_lock.Release();
}
}
private async Task LoadConfigAsync()
{
await LoadConfigInternalAsync();
}
private async Task LoadConfigInternalAsync()
{
await using var dbContext = _dbContextFactory.CreateDbContext();
var routes = await dbContext
.TenantRoutes.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
var newRoutes = new ConcurrentDictionary<string, RouteConfig>();
foreach (var route in routes)
{
var config = new RouteConfig
{
RouteId = route.Id.ToString(),
ClusterId = route.ClusterId,
Match = new RouteMatch { Path = route.PathPattern },
Metadata = new Dictionary<string, string>
{
["TenantCode"] = route.TenantCode,
["ServiceName"] = route.ServiceName,
},
};
newRoutes[route.Id.ToString()] = config;
}
_routes.Clear();
foreach (var route in newRoutes)
{
_routes[route.Key] = route.Value;
}
_logger.LogInformation("Loaded {Count} routes from database", _routes.Count);
}
}

View File

@ -0,0 +1,9 @@
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;
}

View File

@ -0,0 +1,8 @@
namespace YarpGateway.Config;
public class RedisConfig
{
public string ConnectionString { get; set; } = "localhost:6379";
public int Database { get; set; } = 0;
public string InstanceName { get; set; } = "YarpGateway";
}

View File

@ -0,0 +1,272 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using YarpGateway.Data;
using YarpGateway.Config;
using YarpGateway.Models;
using YarpGateway.Services;
using Yarp.ReverseProxy.Configuration;
namespace YarpGateway.Controllers;
[ApiController]
[Route("api/gateway")]
public class GatewayConfigController : ControllerBase
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly DatabaseRouteConfigProvider _routeProvider;
private readonly DatabaseClusterConfigProvider _clusterProvider;
private readonly IRouteCache _routeCache;
public GatewayConfigController(
IDbContextFactory<GatewayDbContext> dbContextFactory,
DatabaseRouteConfigProvider routeProvider,
DatabaseClusterConfigProvider clusterProvider,
IRouteCache routeCache)
{
_dbContextFactory = dbContextFactory;
_routeProvider = routeProvider;
_clusterProvider = clusterProvider;
_routeCache = routeCache;
}
[HttpGet("tenants")]
public async Task<IActionResult> GetTenants()
{
await using var db = _dbContextFactory.CreateDbContext();
var tenants = await db.Tenants
.Where(t => !t.IsDeleted)
.ToListAsync();
return Ok(tenants);
}
[HttpPost("tenants")]
public async Task<IActionResult> CreateTenant([FromBody] CreateTenantDto dto)
{
await using var db = _dbContextFactory.CreateDbContext();
var existing = await db.Tenants
.FirstOrDefaultAsync(t => t.TenantCode == dto.TenantCode);
if (existing != null)
{
return BadRequest($"Tenant code {dto.TenantCode} already exists");
}
var tenant = new GwTenant
{
Id = GenerateId(),
TenantCode = dto.TenantCode,
TenantName = dto.TenantName,
Status = 1
};
await db.Tenants.AddAsync(tenant);
await db.SaveChangesAsync();
return Ok(tenant);
}
[HttpDelete("tenants/{id}")]
public async Task<IActionResult> DeleteTenant(long id)
{
await using var db = _dbContextFactory.CreateDbContext();
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null)
return NotFound();
tenant.IsDeleted = true;
await db.SaveChangesAsync();
return Ok();
}
[HttpGet("tenants/{tenantCode}/routes")]
public async Task<IActionResult> GetTenantRoutes(string tenantCode)
{
await using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes
.Where(r => r.TenantCode == tenantCode && !r.IsDeleted)
.ToListAsync();
return Ok(routes);
}
[HttpPost("tenants/{tenantCode}/routes")]
public async Task<IActionResult> CreateTenantRoute(string tenantCode, [FromBody] CreateTenantRouteDto dto)
{
await using var db = _dbContextFactory.CreateDbContext();
var tenant = await db.Tenants
.FirstOrDefaultAsync(t => t.TenantCode == tenantCode);
if (tenant == null)
return BadRequest($"Tenant {tenantCode} not found");
var clusterId = $"{tenantCode}-{dto.ServiceName}";
var existing = await db.TenantRoutes
.FirstOrDefaultAsync(r => r.ClusterId == clusterId);
if (existing != null)
return BadRequest($"Route for {tenantCode}/{dto.ServiceName} already exists");
var route = new GwTenantRoute
{
Id = GenerateId(),
TenantCode = tenantCode,
ServiceName = dto.ServiceName,
ClusterId = clusterId,
PathPattern = dto.PathPattern,
Priority = 10,
Status = 1,
IsGlobal = false
};
await db.TenantRoutes.AddAsync(route);
await db.SaveChangesAsync();
await _routeCache.ReloadAsync();
return Ok(route);
}
[HttpGet("routes/global")]
public async Task<IActionResult> GetGlobalRoutes()
{
await using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes
.Where(r => r.IsGlobal && !r.IsDeleted)
.ToListAsync();
return Ok(routes);
}
[HttpPost("routes/global")]
public async Task<IActionResult> CreateGlobalRoute([FromBody] CreateGlobalRouteDto dto)
{
await using var db = _dbContextFactory.CreateDbContext();
var existing = await db.TenantRoutes
.FirstOrDefaultAsync(r => r.ServiceName == dto.ServiceName && r.IsGlobal);
if (existing != null)
{
return BadRequest($"Global route for {dto.ServiceName} already exists");
}
var route = new GwTenantRoute
{
Id = GenerateId(),
TenantCode = string.Empty,
ServiceName = dto.ServiceName,
ClusterId = dto.ClusterId,
PathPattern = dto.PathPattern,
Priority = 0,
Status = 1,
IsGlobal = true
};
await db.TenantRoutes.AddAsync(route);
await db.SaveChangesAsync();
await _routeCache.ReloadAsync();
return Ok(route);
}
[HttpDelete("routes/{id}")]
public async Task<IActionResult> DeleteRoute(long id)
{
await using var db = _dbContextFactory.CreateDbContext();
var route = await db.TenantRoutes.FindAsync(id);
if (route == null)
return NotFound();
route.IsDeleted = true;
await db.SaveChangesAsync();
await _routeCache.ReloadAsync();
return Ok();
}
[HttpGet("clusters/{clusterId}/instances")]
public async Task<IActionResult> GetInstances(string clusterId)
{
await using var db = _dbContextFactory.CreateDbContext();
var instances = await db.ServiceInstances
.Where(i => i.ClusterId == clusterId && !i.IsDeleted)
.ToListAsync();
return Ok(instances);
}
[HttpPost("clusters/{clusterId}/instances")]
public async Task<IActionResult> AddInstance(string clusterId, [FromBody] CreateInstanceDto dto)
{
await using var db = _dbContextFactory.CreateDbContext();
var existing = await db.ServiceInstances
.FirstOrDefaultAsync(i => i.ClusterId == clusterId && i.DestinationId == dto.DestinationId);
if (existing != null)
return BadRequest($"Instance {dto.DestinationId} already exists in cluster {clusterId}");
var instance = new GwServiceInstance
{
Id = GenerateId(),
ClusterId = clusterId,
DestinationId = dto.DestinationId,
Address = dto.Address,
Weight = dto.Weight,
Health = 1,
Status = 1
};
await db.ServiceInstances.AddAsync(instance);
await db.SaveChangesAsync();
await _clusterProvider.ReloadAsync();
return Ok(instance);
}
[HttpDelete("instances/{id}")]
public async Task<IActionResult> DeleteInstance(long id)
{
await using var db = _dbContextFactory.CreateDbContext();
var instance = await db.ServiceInstances.FindAsync(id);
if (instance == null)
return NotFound();
instance.IsDeleted = true;
await db.SaveChangesAsync();
await _clusterProvider.ReloadAsync();
return Ok();
}
[HttpPost("reload")]
public async Task<IActionResult> ReloadConfig()
{
await _routeCache.ReloadAsync();
await _routeProvider.ReloadAsync();
await _clusterProvider.ReloadAsync();
return Ok(new { message = "Config reloaded successfully" });
}
private long GenerateId()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
public class CreateTenantDto
{
public string TenantCode { get; set; } = string.Empty;
public string TenantName { get; set; } = string.Empty;
}
public class CreateTenantRouteDto
{
public string ServiceName { get; set; } = string.Empty;
public string PathPattern { get; set; } = string.Empty;
}
public class CreateGlobalRouteDto
{
public string ServiceName { get; set; } = string.Empty;
public string ClusterId { get; set; } = string.Empty;
public string PathPattern { get; set; } = string.Empty;
}
public class CreateInstanceDto
{
public string DestinationId { get; set; } = string.Empty;
public string Address { get; set; } = string.Empty;
public int Weight { get; set; } = 1;
}
}

View File

@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using YarpGateway.Models;
namespace YarpGateway.Data;
public class GatewayDbContext : DbContext
{
public GatewayDbContext(DbContextOptions<GatewayDbContext> options)
: base(options)
{
}
public DbSet<GwTenant> Tenants => Set<GwTenant>();
public DbSet<GwTenantRoute> TenantRoutes => Set<GwTenantRoute>();
public DbSet<GwServiceInstance> ServiceInstances => Set<GwServiceInstance>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GwTenant>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.TenantCode).HasMaxLength(50).IsRequired();
entity.Property(e => e.TenantName).HasMaxLength(100).IsRequired();
entity.HasIndex(e => e.TenantCode).IsUnique();
});
modelBuilder.Entity<GwTenantRoute>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.TenantCode).HasMaxLength(50);
entity.Property(e => e.ServiceName).HasMaxLength(100).IsRequired();
entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired();
entity.Property(e => e.PathPattern).HasMaxLength(200).IsRequired();
entity.HasIndex(e => e.TenantCode);
entity.HasIndex(e => e.ServiceName);
entity.HasIndex(e => e.ClusterId);
entity.HasIndex(e => new { e.ServiceName, e.IsGlobal, e.Status });
});
modelBuilder.Entity<GwServiceInstance>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired();
entity.Property(e => e.DestinationId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Address).HasMaxLength(200).IsRequired();
entity.HasIndex(e => new { e.ClusterId, e.DestinationId }).IsUnique();
entity.HasIndex(e => e.Health);
});
base.OnModelCreating(modelBuilder);
}
}

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace YarpGateway.Data;
public class GatewayDbContextFactory : IDesignTimeDbContextFactory<GatewayDbContext>
{
public GatewayDbContext CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.Build();
var optionsBuilder = new DbContextOptionsBuilder<GatewayDbContext>();
var connectionString = configuration.GetConnectionString("DefaultConnection");
optionsBuilder.UseNpgsql(connectionString);
return new GatewayDbContext(optionsBuilder.Options);
}
}

View File

@ -0,0 +1,14 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "YarpGateway.dll"]

View File

@ -0,0 +1,69 @@
using Microsoft.Extensions.Primitives;
using Yarp.ReverseProxy.Configuration;
using YarpGateway.Config;
namespace YarpGateway.DynamicProxy;
public class DynamicProxyConfigProvider : IProxyConfigProvider
{
private volatile IProxyConfig _config;
private readonly DatabaseRouteConfigProvider _routeProvider;
private readonly DatabaseClusterConfigProvider _clusterProvider;
private readonly object _lock = new();
public DynamicProxyConfigProvider(
DatabaseRouteConfigProvider routeProvider,
DatabaseClusterConfigProvider clusterProvider)
{
_routeProvider = routeProvider;
_clusterProvider = clusterProvider;
UpdateConfig();
}
public IProxyConfig GetConfig()
{
return _config;
}
public void UpdateConfig()
{
lock (_lock)
{
var routes = _routeProvider.GetRoutes();
var clusters = _clusterProvider.GetClusters();
_config = new InMemoryProxyConfig(
routes,
clusters,
Array.Empty<IReadOnlyDictionary<string, string>>()
);
}
}
public async Task ReloadAsync()
{
await _routeProvider.ReloadAsync();
await _clusterProvider.ReloadAsync();
UpdateConfig();
}
private class InMemoryProxyConfig : IProxyConfig
{
private static readonly CancellationChangeToken _nullChangeToken = new(new CancellationToken());
public InMemoryProxyConfig(
IReadOnlyList<RouteConfig> routes,
IReadOnlyList<ClusterConfig> clusters,
IReadOnlyList<IReadOnlyDictionary<string, string>> transforms)
{
Routes = routes;
Clusters = clusters;
Transforms = transforms;
}
public IReadOnlyList<RouteConfig> Routes { get; }
public IReadOnlyList<ClusterConfig> Clusters { get; }
public IReadOnlyList<IReadOnlyDictionary<string, string>> Transforms { get; }
public IChangeToken ChangeToken => _nullChangeToken;
}
}

View File

@ -0,0 +1,243 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using Yarp.ReverseProxy.LoadBalancing;
using Yarp.ReverseProxy.Model;
using YarpGateway.Config;
namespace YarpGateway.LoadBalancing;
public class DistributedWeightedRoundRobinPolicy : ILoadBalancingPolicy
{
private readonly IConnectionMultiplexer _redis;
private readonly RedisConfig _config;
private readonly ILogger<DistributedWeightedRoundRobinPolicy> _logger;
public string Name => "DistributedWeightedRoundRobin";
public DistributedWeightedRoundRobinPolicy(
IConnectionMultiplexer redis,
RedisConfig config,
ILogger<DistributedWeightedRoundRobinPolicy> logger
)
{
_redis = redis;
_config = config;
_logger = logger;
}
public DestinationState? PickDestination(
HttpContext context,
ClusterState cluster,
IReadOnlyList<DestinationState> availableDestinations
)
{
if (availableDestinations.Count == 0)
return null;
if (availableDestinations.Count == 1)
return availableDestinations[0];
var clusterId = cluster.ClusterId;
var db = _redis.GetDatabase();
var lockKey = $"lock:{_config.InstanceName}:{clusterId}";
var stateKey = $"lb:{_config.InstanceName}:{clusterId}:state";
var lockValue = Guid.NewGuid().ToString();
var lockAcquired = db.StringSet(
lockKey,
lockValue,
TimeSpan.FromMilliseconds(500),
When.NotExists
);
if (!lockAcquired)
{
_logger.LogDebug(
"Lock busy for cluster {Cluster}, using fallback selection",
clusterId
);
return FallbackSelection(availableDestinations);
}
try
{
var state = GetOrCreateLoadBalancingState(db, stateKey, availableDestinations);
var selectedDestination = SelectByWeight(state, availableDestinations);
UpdateCurrentWeights(db, stateKey, state, selectedDestination);
_logger.LogDebug(
"Selected {Destination} for cluster {Cluster}",
selectedDestination?.DestinationId,
clusterId
);
return selectedDestination;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in distributed load balancing for cluster {Cluster}",
clusterId
);
return availableDestinations[0];
}
finally
{
var script =
@"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end";
db.ScriptEvaluate(script, new RedisKey[] { lockKey }, new RedisValue[] { lockValue });
}
}
private LoadBalancingState GetOrCreateLoadBalancingState(
IDatabase db,
string stateKey,
IReadOnlyList<DestinationState> destinations
)
{
var existingState = db.StringGet(stateKey);
if (existingState.HasValue)
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
;
var parsedState = (LoadBalancingState?)
System.Text.Json.JsonSerializer.Deserialize<LoadBalancingState>(
existingState.ToString(),
options
);
var version = ComputeConfigHash(destinations);
if (
parsedState != null
&& parsedState.ConfigHash == version
&& parsedState.CurrentWeights != null
)
{
return parsedState;
}
}
var newState = new LoadBalancingState
{
ConfigHash = ComputeConfigHash(destinations),
CurrentWeights = new Dictionary<string, int>(),
};
foreach (var dest in destinations)
{
var weight = GetWeight(dest);
newState.CurrentWeights[dest.DestinationId] = 0;
}
var json = System.Text.Json.JsonSerializer.Serialize(newState);
db.StringSet(stateKey, json, TimeSpan.FromHours(1));
return newState;
}
private long ComputeConfigHash(IReadOnlyList<DestinationState> destinations)
{
var hash = 0L;
foreach (var dest in destinations.OrderBy(d => d.DestinationId))
{
var weight = GetWeight(dest);
hash = HashCode.Combine(hash, dest.DestinationId.GetHashCode());
hash = HashCode.Combine(hash, weight);
}
return hash;
}
private void UpdateCurrentWeights(
IDatabase db,
string stateKey,
LoadBalancingState state,
DestinationState? selected
)
{
if (selected == null)
return;
var json = JsonSerializer.Serialize(state);
db.StringSet(stateKey, json, TimeSpan.FromHours(1));
}
private DestinationState? SelectByWeight(
LoadBalancingState state,
IReadOnlyList<DestinationState> destinations
)
{
int maxWeight = int.MinValue;
int totalWeight = 0;
DestinationState? selected = null;
foreach (var dest in destinations)
{
if (!state.CurrentWeights.ContainsKey(dest.DestinationId))
{
state.CurrentWeights[dest.DestinationId] = 0;
}
var weight = GetWeight(dest);
var currentWeight = state.CurrentWeights[dest.DestinationId];
var newWeight = currentWeight + weight;
state.CurrentWeights[dest.DestinationId] = newWeight;
totalWeight += weight;
if (newWeight > maxWeight)
{
maxWeight = newWeight;
selected = dest;
}
}
if (selected != null)
{
state.CurrentWeights[selected.DestinationId] = maxWeight - totalWeight;
}
return selected;
}
private DestinationState? FallbackSelection(IReadOnlyList<DestinationState> destinations)
{
var hash = ComputeRequestHash();
var index = Math.Abs(hash % destinations.Count);
return destinations[index];
}
private int ComputeRequestHash()
{
var now = DateTime.UtcNow;
return HashCode.Combine(now.Second.GetHashCode(), now.Millisecond.GetHashCode());
}
private int GetWeight(DestinationState destination)
{
if (
destination.Model?.Config?.Metadata?.TryGetValue("Weight", out var weightStr) == true
&& int.TryParse(weightStr, out var weight)
)
{
return weight;
}
return 1;
}
private class LoadBalancingState
{
public long ConfigHash { get; set; }
public Dictionary<string, int> CurrentWeights { get; set; } = new();
}
}

View File

@ -0,0 +1,30 @@
using System.Diagnostics.Metrics;
namespace YarpGateway.Metrics;
public class GatewayMetrics
{
private readonly Counter<int> _requestsTotal;
private readonly Histogram<double> _requestDuration;
public GatewayMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("fengling.gateway");
_requestsTotal = meter.CreateCounter<int>(
"gateway_requests_total",
"Total number of requests");
_requestDuration = meter.CreateHistogram<double>(
"gateway_request_duration_seconds",
"Request duration in seconds");
}
public void RecordRequest(string tenant, string service, int statusCode, double duration)
{
var tag = new KeyValuePair<string, object?>("tenant", tenant);
var tag2 = new KeyValuePair<string, object?>("service", service);
var tag3 = new KeyValuePair<string, object?>("status", statusCode.ToString());
_requestsTotal.Add(1, tag, tag2, tag3);
_requestDuration.Record(duration, tag, tag2);
}
}

View File

@ -0,0 +1,83 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using YarpGateway.Config;
namespace YarpGateway.Middleware;
public class JwtTransformMiddleware
{
private readonly RequestDelegate _next;
private readonly JwtConfig _jwtConfig;
private readonly ILogger<JwtTransformMiddleware> _logger;
public JwtTransformMiddleware(
RequestDelegate next,
IOptions<JwtConfig> jwtConfig,
ILogger<JwtTransformMiddleware> logger
)
{
_next = next;
_jwtConfig = jwtConfig.Value;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
{
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)
.Select(c => c.Value)
.ToList();
if (!string.IsNullOrEmpty(tenantId))
{
context.Request.Headers["X-Tenant-Id"] = tenantId;
if (!string.IsNullOrEmpty(userId))
context.Request.Headers["X-User-Id"] = userId;
if (!string.IsNullOrEmpty(userName))
context.Request.Headers["X-User-Name"] = userName;
if (roles.Any())
context.Request.Headers["X-Roles"] = string.Join(",", roles);
_logger.LogInformation(
"JWT transformed - Tenant: {Tenant}, User: {User}",
tenantId,
userId
);
}
else
{
_logger.LogWarning("JWT missing tenant claim");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse JWT token");
}
await _next(context);
}
}

View File

@ -0,0 +1,63 @@
using Microsoft.Extensions.Options;
using System.Text.RegularExpressions;
using YarpGateway.Services;
namespace YarpGateway.Middleware;
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;
}
public async Task InvokeAsync(HttpContext context)
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId))
{
await _next(context);
return;
}
var path = context.Request.Path.Value ?? string.Empty;
var serviceName = ExtractServiceName(path);
if (string.IsNullOrEmpty(serviceName))
{
await _next(context);
return;
}
var route = _routeCache.GetRoute(tenantId, serviceName);
if (route == null)
{
_logger.LogWarning("Route not found - Tenant: {Tenant}, Service: {Service}", tenantId, serviceName);
await _next(context);
return;
}
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);
await _next(context);
}
private string ExtractServiceName(string path)
{
var match = Regex.Match(path, @"/api/(\w+)/?");
return match.Success ? match.Groups[1].Value : string.Empty;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,89 @@
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
START TRANSACTION;
CREATE TABLE "ServiceInstances" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"ClusterId" character varying(100) NOT NULL,
"DestinationId" character varying(100) NOT NULL,
"Address" character varying(200) NOT NULL,
"Health" integer NOT NULL,
"Weight" integer NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_ServiceInstances" PRIMARY KEY ("Id")
);
CREATE TABLE "Tenants" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"TenantName" character varying(100) NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_Tenants" PRIMARY KEY ("Id"),
CONSTRAINT "AK_Tenants_TenantCode" UNIQUE ("TenantCode")
);
CREATE TABLE "TenantRoutes" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"ServiceName" character varying(100) NOT NULL,
"ClusterId" character varying(100) NOT NULL,
"PathPattern" character varying(200) NOT NULL,
"Priority" integer NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_TenantRoutes" PRIMARY KEY ("Id"),
CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode" FOREIGN KEY ("TenantCode") REFERENCES "Tenants" ("TenantCode") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX "IX_ServiceInstances_ClusterId_DestinationId" ON "ServiceInstances" ("ClusterId", "DestinationId");
CREATE INDEX "IX_ServiceInstances_Health" ON "ServiceInstances" ("Health");
CREATE INDEX "IX_TenantRoutes_ClusterId" ON "TenantRoutes" ("ClusterId");
CREATE UNIQUE INDEX "IX_TenantRoutes_TenantCode_ServiceName" ON "TenantRoutes" ("TenantCode", "ServiceName");
CREATE UNIQUE INDEX "IX_Tenants_TenantCode" ON "Tenants" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201120312_InitialCreate', '9.0.0');
ALTER TABLE "TenantRoutes" DROP CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode";
ALTER TABLE "Tenants" DROP CONSTRAINT "AK_Tenants_TenantCode";
DROP INDEX "IX_TenantRoutes_TenantCode_ServiceName";
ALTER TABLE "TenantRoutes" ADD "IsGlobal" boolean NOT NULL DEFAULT FALSE;
CREATE INDEX "IX_TenantRoutes_ServiceName" ON "TenantRoutes" ("ServiceName");
CREATE INDEX "IX_TenantRoutes_ServiceName_IsGlobal_Status" ON "TenantRoutes" ("ServiceName", "IsGlobal", "Status");
CREATE INDEX "IX_TenantRoutes_TenantCode" ON "TenantRoutes" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201133826_AddIsGlobalToTenantRoute', '9.0.0');
COMMIT;

View File

@ -0,0 +1,18 @@
namespace YarpGateway.Models;
public class GwServiceInstance
{
public long Id { get; set; }
public string ClusterId { get; set; } = string.Empty;
public string DestinationId { get; set; } = string.Empty;
public string Address { get; set; } = string.Empty;
public int Health { get; set; } = 1;
public int Weight { get; set; } = 1;
public int Status { get; set; } = 1;
public long? CreatedBy { get; set; }
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
public long? UpdatedBy { get; set; }
public DateTime? UpdatedTime { get; set; }
public bool IsDeleted { get; set; } = false;
public int Version { get; set; } = 0;
}

View File

@ -0,0 +1,15 @@
namespace YarpGateway.Models;
public class GwTenant
{
public long Id { get; set; }
public string TenantCode { get; set; } = string.Empty;
public string TenantName { get; set; } = string.Empty;
public int Status { get; set; } = 1;
public long? CreatedBy { get; set; }
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
public long? UpdatedBy { get; set; }
public DateTime? UpdatedTime { get; set; }
public bool IsDeleted { get; set; } = false;
public int Version { get; set; } = 0;
}

View File

@ -0,0 +1,19 @@
namespace YarpGateway.Models;
public class GwTenantRoute
{
public long Id { get; set; }
public string TenantCode { get; set; } = string.Empty;
public string ServiceName { get; set; } = string.Empty;
public string ClusterId { get; set; } = string.Empty;
public string PathPattern { get; set; } = string.Empty;
public int Priority { get; set; } = 0;
public int Status { get; set; } = 1;
public bool IsGlobal { get; set; } = false;
public long? CreatedBy { get; set; }
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
public long? UpdatedBy { get; set; }
public DateTime? UpdatedTime { get; set; }
public bool IsDeleted { get; set; } = false;
public int Version { get; set; } = 0;
}

113
src/YarpGateway/Program.cs Normal file
View File

@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Serilog;
using Yarp.ReverseProxy.Configuration;
using Yarp.ReverseProxy.LoadBalancing;
using YarpGateway.Config;
using YarpGateway.Data;
using YarpGateway.DynamicProxy;
using YarpGateway.LoadBalancing;
using YarpGateway.Middleware;
using YarpGateway.Services;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog(
(context, services, configuration) =>
configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
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);
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.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;
var connection = ConnectionMultiplexer.Connect(connectionOptions);
connection.ConnectionFailed += (sender, e) =>
{
Serilog.Log.Error(e.Exception, "Redis connection failed");
};
connection.ConnectionRestored += (sender, e) =>
{
Serilog.Log.Information("Redis connection restored");
};
return connection;
});
builder.Services.AddSingleton<ILoadBalancingPolicy, DistributedWeightedRoundRobinPolicy>();
builder.Services.AddSingleton<DynamicProxyConfigProvider>();
builder.Services.AddSingleton<IProxyConfigProvider>(sp => sp.GetRequiredService<DynamicProxyConfigProvider>());
var corsSettings = builder.Configuration.GetSection("Cors");
builder.Services.AddCors(options =>
{
var allowAnyOrigin = corsSettings.GetValue<bool>("AllowAnyOrigin");
var allowedOrigins = corsSettings.GetSection("AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
options.AddPolicy("AllowFrontend", policy =>
{
if (allowAnyOrigin)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(allowedOrigins);
}
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseCors("AllowFrontend");
app.UseMiddleware<JwtTransformMiddleware>();
app.UseMiddleware<TenantRoutingMiddleware>();
app.MapControllers();
app.MapReverseProxy();
await app.Services.GetRequiredService<IRouteCache>().InitializeAsync();
try
{
Log.Information("Starting YARP Gateway");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5046",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,138 @@
using StackExchange.Redis;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using YarpGateway.Config;
namespace YarpGateway.Services;
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);
}
public class RedisConnectionManager : IRedisConnectionManager
{
private readonly Lazy<IConnectionMultiplexer> _lazyConnection;
private readonly RedisConfig _config;
private readonly ILogger<RedisConnectionManager> _logger;
public RedisConnectionManager(RedisConfig config, ILogger<RedisConnectionManager> logger)
{
_config = config;
_logger = logger;
_lazyConnection = new Lazy<IConnectionMultiplexer>(() =>
{
var configuration = ConfigurationOptions.Parse(_config.ConnectionString);
configuration.AbortOnConnectFail = false;
configuration.ConnectRetry = 3;
configuration.ConnectTimeout = 5000;
configuration.SyncTimeout = 3000;
configuration.DefaultDatabase = _config.Database;
var connection = ConnectionMultiplexer.Connect(configuration);
connection.ConnectionRestored += (sender, e) =>
{
_logger.LogInformation("Redis connection restored");
};
connection.ConnectionFailed += (sender, e) =>
{
_logger.LogError(e.Exception, "Redis connection failed");
};
_logger.LogInformation("Connected to Redis at {ConnectionString}", _config.ConnectionString);
return connection;
});
}
public IConnectionMultiplexer GetConnection()
{
return _lazyConnection.Value;
}
public async Task<IDisposable> AcquireLockAsync(string key, TimeSpan? expiry = null)
{
var expiryTime = expiry ?? TimeSpan.FromSeconds(10);
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);
var retryCount = 0;
const int maxRetries = 50;
while (!acquired && retryCount < maxRetries)
{
await Task.Delay(backoff);
acquired = await db.StringSetAsync(lockKey, lockValue, expiryTime, When.NotExists);
retryCount++;
if (retryCount < 10)
{
backoff = TimeSpan.FromMilliseconds(100 * (retryCount + 1));
}
}
if (!acquired)
{
throw new TimeoutException($"Failed to acquire lock for key: {lockKey}");
}
}
return new RedisLock(db, lockKey, lockValue, _logger);
}
public async Task<T> ExecuteInLockAsync<T>(string key, Func<Task<T>> func, TimeSpan? expiry = null)
{
using var @lock = await AcquireLockAsync(key, expiry);
return await func();
}
private class RedisLock : IDisposable
{
private readonly IDatabase _db;
private readonly string _key;
private readonly string _value;
private readonly ILogger _logger;
private bool _disposed;
public RedisLock(IDatabase db, string key, string value, ILogger logger)
{
_db = db;
_key = key;
_value = value;
_logger = logger;
}
public void Dispose()
{
if (_disposed) return;
try
{
var script = @"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end";
_db.ScriptEvaluate(script, new RedisKey[] { _key }, new RedisValue[] { _value });
_logger.LogDebug("Released lock for key: {Key}", _key);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to release lock for key: {Key}", _key);
}
finally
{
_disposed = true;
}
}
}
}

View File

@ -0,0 +1,138 @@
using System.Collections.Concurrent;
using YarpGateway.Models;
using YarpGateway.Data;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
namespace YarpGateway.Services;
public class RouteInfo
{
public long Id { get; set; }
public string ClusterId { get; set; } = string.Empty;
public string PathPattern { get; set; } = string.Empty;
public int Priority { get; set; }
public bool IsGlobal { get; set; }
}
public interface IRouteCache
{
Task InitializeAsync();
Task ReloadAsync();
RouteInfo? GetRoute(string tenantCode, string serviceName);
RouteInfo? GetRouteByPath(string path);
}
public class RouteCache : IRouteCache
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly ILogger<RouteCache> _logger;
private readonly ConcurrentDictionary<string, RouteInfo> _globalRoutes = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, RouteInfo>> _tenantRoutes = new();
private readonly ConcurrentDictionary<string, RouteInfo> _pathRoutes = new();
private readonly ReaderWriterLockSlim _lock = new();
public RouteCache(IDbContextFactory<GatewayDbContext> dbContextFactory, ILogger<RouteCache> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task InitializeAsync()
{
_logger.LogInformation("Initializing route cache from database...");
await LoadFromDatabaseAsync();
_logger.LogInformation("Route cache initialized: {GlobalCount} global routes, {TenantCount} tenant routes",
_globalRoutes.Count, _tenantRoutes.Count);
}
public async Task ReloadAsync()
{
_logger.LogInformation("Reloading route cache...");
await LoadFromDatabaseAsync();
_logger.LogInformation("Route cache reloaded");
}
public RouteInfo? GetRoute(string tenantCode, string serviceName)
{
_lock.EnterUpgradeableReadLock();
try
{
// 1. 优先查找租户专用路由
if (_tenantRoutes.TryGetValue(tenantCode, out var tenantRouteMap) &&
tenantRouteMap.TryGetValue(serviceName, out var tenantRoute))
{
_logger.LogDebug("Found tenant-specific route: {Tenant}/{Service} -> {Cluster}",
tenantCode, serviceName, tenantRoute.ClusterId);
return tenantRoute;
}
// 2. 查找全局路由
if (_globalRoutes.TryGetValue(serviceName, out var globalRoute))
{
_logger.LogDebug("Found global route: {Service} -> {Cluster} for tenant {Tenant}",
serviceName, globalRoute.ClusterId, tenantCode);
return globalRoute;
}
// 3. 没找到
_logger.LogWarning("No route found for: {Tenant}/{Service}", tenantCode, serviceName);
return null;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
public RouteInfo? GetRouteByPath(string path)
{
return _pathRoutes.TryGetValue(path, out var route) ? route : null;
}
private async Task LoadFromDatabaseAsync()
{
using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes
.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
_lock.EnterWriteLock();
try
{
_globalRoutes.Clear();
_tenantRoutes.Clear();
_pathRoutes.Clear();
foreach (var route in routes)
{
var routeInfo = new RouteInfo
{
Id = route.Id,
ClusterId = route.ClusterId,
PathPattern = route.PathPattern,
Priority = route.Priority,
IsGlobal = route.IsGlobal
};
if (route.IsGlobal)
{
_globalRoutes[route.ServiceName] = routeInfo;
_pathRoutes[route.PathPattern] = routeInfo;
}
else if (!string.IsNullOrEmpty(route.TenantCode))
{
_tenantRoutes.GetOrAdd(route.TenantCode, _ => new())
[route.ServiceName] = routeInfo;
_pathRoutes[route.PathPattern] = routeInfo;
}
}
}
finally
{
_lock.ExitWriteLock();
}
}
}

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,64 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Yarp.ReverseProxy": "Information"
}
},
"AllowedHosts": "*",
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174"
],
"AllowAnyOrigin": false
},
"ConnectionStrings": {
"DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
},
"Jwt": {
"Authority": "https://your-auth-server.com",
"Audience": "fengling-gateway",
"ValidateIssuer": true,
"ValidateAudience": true
},
"Redis": {
"ConnectionString": "192.168.100.10:6379",
"Database": 0,
"InstanceName": "YarpGateway"
},
"ReverseProxy": {
"Routes": {},
"Clusters": {}
},
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": "Information",
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/gateway-.log",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:8080"
}
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More