chore(auth): upgrade OpenIddict to 7.2.0
This commit is contained in:
parent
6ca282d208
commit
4ffc256615
13
.config/dotnet-tools.json
Normal file
13
.config/dotnet-tools.json
Normal 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
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"deepscan.enable": true
|
||||
}
|
||||
39
Fengling.Refactory.Buiding.sln
Normal file
39
Fengling.Refactory.Buiding.sln
Normal 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
288
OpenCode.md
Normal 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
370
README.md
Normal 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
20
docker/docker-compose.yml
Normal 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
|
||||
1023
docs/plans/2025-02-01-auth-service.md
Normal file
1023
docs/plans/2025-02-01-auth-service.md
Normal file
File diff suppressed because it is too large
Load Diff
216
docs/plans/2025-02-01-microservices-architecture.md
Normal file
216
docs/plans/2025-02-01-microservices-architecture.md
Normal 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
|
||||
@ -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
398
docs/testing-guide.md
Normal 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
89
sql/init.sql
Normal 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;
|
||||
|
||||
@ -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" />
|
||||
|
||||
1
src/YarpGateway.Admin/.env.development
Normal file
1
src/YarpGateway.Admin/.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
24
src/YarpGateway.Admin/.gitignore
vendored
Normal file
24
src/YarpGateway.Admin/.gitignore
vendored
Normal 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?
|
||||
3
src/YarpGateway.Admin/.vscode/extensions.json
vendored
Normal file
3
src/YarpGateway.Admin/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
18
src/YarpGateway.Admin/Dockerfile
Normal file
18
src/YarpGateway.Admin/Dockerfile
Normal 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;"]
|
||||
167
src/YarpGateway.Admin/README.md
Normal file
167
src/YarpGateway.Admin/README.md
Normal 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
|
||||
13
src/YarpGateway.Admin/index.html
Normal file
13
src/YarpGateway.Admin/index.html
Normal 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>
|
||||
21
src/YarpGateway.Admin/nginx.conf
Normal file
21
src/YarpGateway.Admin/nginx.conf
Normal 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
2560
src/YarpGateway.Admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
src/YarpGateway.Admin/package.json
Normal file
27
src/YarpGateway.Admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
src/YarpGateway.Admin/public/vite.svg
Normal file
1
src/YarpGateway.Admin/public/vite.svg
Normal 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 |
27
src/YarpGateway.Admin/src/App.vue
Normal file
27
src/YarpGateway.Admin/src/App.vue
Normal 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>
|
||||
92
src/YarpGateway.Admin/src/api/index.ts
Normal file
92
src/YarpGateway.Admin/src/api/index.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
1
src/YarpGateway.Admin/src/assets/vue.svg
Normal file
1
src/YarpGateway.Admin/src/assets/vue.svg
Normal 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 |
41
src/YarpGateway.Admin/src/components/HelloWorld.vue
Normal file
41
src/YarpGateway.Admin/src/components/HelloWorld.vue
Normal 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>
|
||||
132
src/YarpGateway.Admin/src/components/Layout.vue
Normal file
132
src/YarpGateway.Admin/src/components/Layout.vue
Normal 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>
|
||||
20
src/YarpGateway.Admin/src/main.ts
Normal file
20
src/YarpGateway.Admin/src/main.ts
Normal 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')
|
||||
45
src/YarpGateway.Admin/src/router/index.ts
Normal file
45
src/YarpGateway.Admin/src/router/index.ts
Normal 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
|
||||
43
src/YarpGateway.Admin/src/stores/tenant.ts
Normal file
43
src/YarpGateway.Admin/src/stores/tenant.ts
Normal 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
|
||||
}
|
||||
})
|
||||
23
src/YarpGateway.Admin/src/style.css
Normal file
23
src/YarpGateway.Admin/src/style.css
Normal 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;
|
||||
}
|
||||
145
src/YarpGateway.Admin/src/views/ClusterInstances.vue
Normal file
145
src/YarpGateway.Admin/src/views/ClusterInstances.vue
Normal 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>
|
||||
226
src/YarpGateway.Admin/src/views/Dashboard.vue
Normal file
226
src/YarpGateway.Admin/src/views/Dashboard.vue
Normal 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>
|
||||
128
src/YarpGateway.Admin/src/views/GlobalRoutes.vue
Normal file
128
src/YarpGateway.Admin/src/views/GlobalRoutes.vue
Normal 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>
|
||||
120
src/YarpGateway.Admin/src/views/TenantList.vue
Normal file
120
src/YarpGateway.Admin/src/views/TenantList.vue
Normal 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>
|
||||
139
src/YarpGateway.Admin/src/views/TenantRoutes.vue
Normal file
139
src/YarpGateway.Admin/src/views/TenantRoutes.vue
Normal 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>
|
||||
16
src/YarpGateway.Admin/tsconfig.app.json
Normal file
16
src/YarpGateway.Admin/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
src/YarpGateway.Admin/tsconfig.json
Normal file
7
src/YarpGateway.Admin/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
src/YarpGateway.Admin/tsconfig.node.json
Normal file
26
src/YarpGateway.Admin/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
21
src/YarpGateway.Admin/vite.config.ts
Normal file
21
src/YarpGateway.Admin/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
99
src/YarpGateway/Config/DatabaseClusterConfigProvider.cs
Normal file
99
src/YarpGateway/Config/DatabaseClusterConfigProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
83
src/YarpGateway/Config/DatabaseRouteConfigProvider.cs
Normal file
83
src/YarpGateway/Config/DatabaseRouteConfigProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/YarpGateway/Config/JwtConfig.cs
Normal file
9
src/YarpGateway/Config/JwtConfig.cs
Normal 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;
|
||||
}
|
||||
8
src/YarpGateway/Config/RedisConfig.cs
Normal file
8
src/YarpGateway/Config/RedisConfig.cs
Normal 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";
|
||||
}
|
||||
272
src/YarpGateway/Controllers/GatewayConfigController.cs
Normal file
272
src/YarpGateway/Controllers/GatewayConfigController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/YarpGateway/Data/GatewayDbContext.cs
Normal file
52
src/YarpGateway/Data/GatewayDbContext.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/YarpGateway/Data/GatewayDbContextFactory.cs
Normal file
22
src/YarpGateway/Data/GatewayDbContextFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/YarpGateway/Dockerfile
Normal file
14
src/YarpGateway/Dockerfile
Normal 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"]
|
||||
69
src/YarpGateway/DynamicProxy/DynamicProxyConfigProvider.cs
Normal file
69
src/YarpGateway/DynamicProxy/DynamicProxyConfigProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
30
src/YarpGateway/Metrics/GatewayMetrics.cs
Normal file
30
src/YarpGateway/Metrics/GatewayMetrics.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
83
src/YarpGateway/Middleware/JwtTransformMiddleware.cs
Normal file
83
src/YarpGateway/Middleware/JwtTransformMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
63
src/YarpGateway/Middleware/TenantRoutingMiddleware.cs
Normal file
63
src/YarpGateway/Middleware/TenantRoutingMiddleware.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
209
src/YarpGateway/Migrations/20260201120312_InitialCreate.Designer.cs
generated
Normal file
209
src/YarpGateway/Migrations/20260201120312_InitialCreate.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/YarpGateway/Migrations/20260201120312_InitialCreate.cs
Normal file
133
src/YarpGateway/Migrations/20260201120312_InitialCreate.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
205
src/YarpGateway/Migrations/20260201133826_AddIsGlobalToTenantRoute.Designer.cs
generated
Normal file
205
src/YarpGateway/Migrations/20260201133826_AddIsGlobalToTenantRoute.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/YarpGateway/Migrations/GatewayDbContextModelSnapshot.cs
Normal file
202
src/YarpGateway/Migrations/GatewayDbContextModelSnapshot.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/YarpGateway/Migrations/script.sql
Normal file
89
src/YarpGateway/Migrations/script.sql
Normal 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;
|
||||
|
||||
18
src/YarpGateway/Models/GwServiceInstance.cs
Normal file
18
src/YarpGateway/Models/GwServiceInstance.cs
Normal 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;
|
||||
}
|
||||
15
src/YarpGateway/Models/GwTenant.cs
Normal file
15
src/YarpGateway/Models/GwTenant.cs
Normal 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;
|
||||
}
|
||||
19
src/YarpGateway/Models/GwTenantRoute.cs
Normal file
19
src/YarpGateway/Models/GwTenantRoute.cs
Normal 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
113
src/YarpGateway/Program.cs
Normal 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();
|
||||
}
|
||||
14
src/YarpGateway/Properties/launchSettings.json
Normal file
14
src/YarpGateway/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/YarpGateway/Services/RedisConnectionManager.cs
Normal file
138
src/YarpGateway/Services/RedisConnectionManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/YarpGateway/Services/RouteCache.cs
Normal file
138
src/YarpGateway/Services/RouteCache.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/YarpGateway/YarpGateway.csproj
Normal file
23
src/YarpGateway/YarpGateway.csproj
Normal 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>
|
||||
8
src/YarpGateway/appsettings.Development.json
Normal file
8
src/YarpGateway/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/YarpGateway/appsettings.json
Normal file
64
src/YarpGateway/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/YarpGateway/bin/Debug/net10.0/Humanizer.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Humanizer.dll
Executable file
Binary file not shown.
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.Bcl.AsyncInterfaces.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.Bcl.AsyncInterfaces.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.Build.Locator.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.Build.Locator.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.CSharp.Workspaces.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.CSharp.Workspaces.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.CSharp.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.CSharp.dll
Executable file
Binary file not shown.
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.Workspaces.MSBuild.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.Workspaces.MSBuild.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.Workspaces.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.Workspaces.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.CodeAnalysis.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.Abstractions.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.Abstractions.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.Design.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.Design.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.Relational.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.Relational.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.EntityFrameworkCore.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.Extensions.DependencyModel.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.Extensions.DependencyModel.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Abstractions.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Abstractions.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Logging.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Logging.dll
Executable file
Binary file not shown.
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Protocols.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Protocols.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Tokens.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Microsoft.IdentityModel.Tokens.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Mono.TextTemplating.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Mono.TextTemplating.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Npgsql.EntityFrameworkCore.PostgreSQL.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Npgsql.EntityFrameworkCore.PostgreSQL.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Npgsql.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Npgsql.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Pipelines.Sockets.Unofficial.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Pipelines.Sockets.Unofficial.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.AspNetCore.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.AspNetCore.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Extensions.Hosting.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Extensions.Hosting.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Extensions.Logging.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Extensions.Logging.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Formatting.Compact.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Formatting.Compact.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Settings.Configuration.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Settings.Configuration.dll
Executable file
Binary file not shown.
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Sinks.Console.dll
Executable file
BIN
src/YarpGateway/bin/Debug/net10.0/Serilog.Sinks.Console.dll
Executable file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user