feat(gateway-config): 实现 K8s 服务监听和待确认配置管理基础结构
All checks were successful
Build and Push Docker / build (push) Successful in 4m28s

本次提交包含网关配置重构的第一阶段实现(IMPL-1, IMPL-4):

## 新增功能

### 1. K8s 服务监听服务 (IMPL-1)
- 新增 K8sServiceWatchService - 后台服务监听 Kubernetes Service 变化
- 支持 Watch API 和自动重连机制(指数退避策略)
- 使用 Channel 处理事件,防止内存无限增长
- 支持集群内配置和本地 kubeconfig 两种模式

### 2. 待确认配置管理 (IMPL-4)
- 新增 PendingConfig 实体,用于存储待用户确认的配置变更
- 新增 PendingConfigType 枚举(Route, Cluster, Destination)
- 新增 PendingConfigSource 枚举(K8sDiscovery, Manual)
- 新增 PendingConfigStatus 枚举(Pending, Confirmed, Rejected, Modified)
- 生成 EF Core 迁移 AddPendingConfig,创建 gw_pending_configs 表

## 技术变更

### NuGet 包
- 添加 KubernetesClient 16.0.2 包引用

### 数据库
- 更新 ConsoleDbContext,添加 PendingConfigs DbSet
- 配置 PendingConfig 表索引(Status, ServiceName, CreatedAt)

### EF Core 兼容性修复
- 修复 EF Core 10 中 JSON 值对象的映射问题
- 使用值转换器替代 OwnsOne().ToJson() 配置

## 文件变更
- 新增: src/Services/K8sServiceWatchService.cs
- 新增: src/Models/Entities/PendingConfig.cs
- 新增: src/Models/Entities/PendingConfigEnums.cs
- 新增: src/Migrations/20260307161935_AddPendingConfig.cs
- 修改: src/Program.cs (注册 K8sServiceWatch)
- 修改: src/Data/ConsoleDbContext.cs (添加实体配置)
- 修改: Directory.Packages.props (添加 KubernetesClient)

关联任务: IMPL-1, IMPL-4
关联重构计划: WFS-gateway-refactor
This commit is contained in:
movingsam 2026-03-08 00:32:30 +08:00
parent 154484d2dc
commit 3ec055b871
10 changed files with 1960 additions and 81 deletions

View File

@ -23,6 +23,8 @@
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="7.2.0" />
<!-- Swashbuckle -->
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.4" />
<!-- Kubernetes -->
<PackageVersion Include="KubernetesClient" Version="16.0.2" />
<!-- Graphics -->
<PackageVersion Include="SkiaSharp" Version="3.119.2" />
<PackageVersion Include="QRCoder" Version="1.7.0" />

View File

@ -1,3 +1,4 @@
using Fengling.Console.Models.Entities;
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
using Fengling.Platform.Domain.AggregatesModel.RoleAggregate;
using Fengling.Platform.Domain.AggregatesModel.TenantAggregate;
@ -6,6 +7,7 @@ using Fengling.Platform.Infrastructure;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Fengling.Console.Data;
@ -19,16 +21,46 @@ public class ConsoleDbContext : PlatformDbContext
{
}
/// <summary>
/// 待确认配置表
/// </summary>
public DbSet<PendingConfig> PendingConfigs { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// ========== Gateway 模块 ==========
modelBuilder.Entity<GwTenantRoute>(entity =>
// ========== PendingConfig 模块 ==========
modelBuilder.Entity<PendingConfig>(entity =>
{
entity.ToTable("gw_tenant_routes");
entity.ToTable("gw_pending_configs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasMaxLength(32);
entity.Property(e => e.SourceId).HasMaxLength(128);
entity.Property(e => e.ServiceName).HasMaxLength(256);
entity.Property(e => e.TenantCode).HasMaxLength(64);
entity.Property(e => e.ClusterId).HasMaxLength(64);
entity.Property(e => e.ConfirmedBy).HasMaxLength(128);
entity.Property(e => e.ConfigJson).HasColumnType("text");
// 创建索引
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.Source);
entity.HasIndex(e => e.Type);
entity.HasIndex(e => new { e.TenantCode, e.Status });
entity.HasIndex(e => new { e.ServiceName, e.Status });
});
// ========== Gateway 模块 ==========
// 忽略这些类型,让它们只作为 JSON 值对象使用(与 PlatformDbContext 保持一致)
modelBuilder.Ignore<GwRouteMatch>();
modelBuilder.Ignore<GwRouteHeader>();
modelBuilder.Ignore<GwRouteQueryParameter>();
modelBuilder.Ignore<GwTransform>();
// GwTenantRoute 已在 PlatformDbContext 中配置,这里只修改表名
modelBuilder.Entity<GwTenantRoute>().ToTable("gw_tenant_routes");
// ========== Tenant 模块 ==========
modelBuilder.Entity<Tenant>(entity =>
{

View File

@ -29,6 +29,7 @@
<PackageReference Include="OpenIddict.EntityFrameworkCore" />
<PackageReference Include="OpenIddict.Server" />
<PackageReference Include="OpenIddict.Server.AspNetCore" />
<PackageReference Include="KubernetesClient" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="QRCoder" />
<PackageReference Include="Swashbuckle.AspNetCore" />

View File

@ -0,0 +1,868 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Fengling.Console.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Fengling.Console.Migrations
{
[DbContext(typeof(ConsoleDbContext))]
[Migration("20260307161935_AddPendingConfig")]
partial class AddPendingConfig
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Fengling.Console.Models.Entities.PendingConfig", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ConfigJson")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConfirmedBy")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsModified")
.HasColumnType("boolean");
b.Property<bool>("IsNew")
.HasColumnType("boolean");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Source");
b.HasIndex("Status");
b.HasIndex("Type");
b.HasIndex("ServiceName", "Status");
b.HasIndex("TenantCode", "Status");
b.ToTable("gw_pending_configs", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
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>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("LoadBalancingPolicy")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
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.HasKey("Id");
b.HasIndex("ClusterId")
.IsUnique();
b.HasIndex("Name");
b.HasIndex("Status");
b.ToTable("GwClusters");
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwTenantRoute", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AuthorizationPolicy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("CorsPolicy")
.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>("LoadBalancingPolicy")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("RateLimiterPolicy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
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<int?>("TimeoutSeconds")
.HasColumnType("integer");
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("gw_tenant_routes", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.RoleAggregate.ApplicationRole", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.PrimitiveCollection<List<string>>("Permissions")
.HasColumnType("text[]");
b.Property<long?>("TenantId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("idn_roles", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.TenantAggregate.Tenant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContactEmail")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int?>("MaxUsers")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("RowVersion")
.HasColumnType("bigint");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Status");
b.HasIndex("TenantCode")
.IsUnique();
b.ToTable("sys_tenants", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.AccessLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Duration")
.HasColumnType("integer");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Method")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("RequestData")
.HasColumnType("text");
b.Property<string>("Resource")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ResponseData")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TenantId")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("UserName")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Action");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.HasIndex("TenantId");
b.HasIndex("UserName");
b.ToTable("sys_access_logs", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("RealName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("PhoneNumber")
.IsUnique();
b.ToTable("idn_users", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NewValue")
.HasColumnType("text");
b.Property<string>("OldValue")
.HasColumnType("text");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Operator")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<long?>("TargetId")
.HasColumnType("bigint");
b.Property<string>("TargetName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TargetType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TenantId")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Action");
b.HasIndex("CreatedAt");
b.HasIndex("Operation");
b.HasIndex("Operator");
b.HasIndex("TenantId");
b.ToTable("sys_audit_logs", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<long>("RoleId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("idn_role_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("idn_user_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("idn_user_logins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<long>("RoleId")
.HasColumnType("bigint");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("idn_user_roles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("idn_user_tokens", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b =>
{
b.OwnsMany("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwDestination", "Destinations", b1 =>
{
b1.Property<string>("ClusterId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b1.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b1.Property<string>("Health")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b1.Property<int>("HealthStatus")
.HasColumnType("integer");
b1.Property<int>("Status")
.HasColumnType("integer");
b1.Property<int>("Weight")
.HasColumnType("integer");
b1.HasKey("ClusterId", "Id");
b1.HasIndex("ClusterId", "DestinationId");
b1.ToTable("GwDestination");
b1.WithOwner()
.HasForeignKey("ClusterId");
});
b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwHealthCheckConfig", "HealthCheck", b1 =>
{
b1.Property<string>("GwClusterId");
b1.Property<bool>("Enabled");
b1.Property<int>("IntervalSeconds");
b1.Property<string>("Path");
b1.Property<int>("TimeoutSeconds");
b1.HasKey("GwClusterId");
b1.ToTable("GwClusters");
b1.ToJson("HealthCheck");
b1.WithOwner()
.HasForeignKey("GwClusterId");
});
b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwSessionAffinityConfig", "SessionAffinity", b1 =>
{
b1.Property<string>("GwClusterId");
b1.Property<string>("AffinityKeyName")
.IsRequired();
b1.Property<bool>("Enabled");
b1.Property<string>("Policy")
.IsRequired();
b1.HasKey("GwClusterId");
b1.ToTable("GwClusters");
b1.ToJson("SessionAffinity");
b1.WithOwner()
.HasForeignKey("GwClusterId");
});
b.Navigation("Destinations");
b.Navigation("HealthCheck");
b.Navigation("SessionAffinity");
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", b =>
{
b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.TenantAggregate.TenantInfo", "TenantInfo", b1 =>
{
b1.Property<long>("ApplicationUserId")
.HasColumnType("bigint");
b1.Property<string>("TenantCode")
.HasColumnType("text")
.HasColumnName("TenantCode");
b1.Property<long?>("TenantId")
.HasColumnType("bigint")
.HasColumnName("TenantId");
b1.Property<string>("TenantName")
.HasColumnType("text")
.HasColumnName("TenantName");
b1.HasKey("ApplicationUserId");
b1.ToTable("idn_users");
b1.WithOwner()
.HasForeignKey("ApplicationUserId");
});
b.Navigation("TenantInfo");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
{
b.HasOne("Fengling.Platform.Domain.AggregatesModel.RoleAggregate.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
{
b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
{
b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
{
b.HasOne("Fengling.Platform.Domain.AggregatesModel.RoleAggregate.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
{
b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,281 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Fengling.Console.Migrations
{
/// <inheritdoc />
public partial class AddPendingConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gw_service_instances");
migrationBuilder.DropTable(
name: "gw_tenants");
migrationBuilder.DropColumn(
name: "PathPattern",
table: "gw_tenant_routes");
migrationBuilder.AddColumn<string>(
name: "AuthorizationPolicy",
table: "gw_tenant_routes",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CorsPolicy",
table: "gw_tenant_routes",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LoadBalancingPolicy",
table: "gw_tenant_routes",
type: "character varying(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RateLimiterPolicy",
table: "gw_tenant_routes",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TimeoutSeconds",
table: "gw_tenant_routes",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "gw_pending_configs",
columns: table => new
{
Id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
SourceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
ConfigJson = table.Column<string>(type: "text", nullable: false),
ServiceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
TenantCode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ConfirmedBy = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
IsNew = table.Column<bool>(type: "boolean", nullable: false),
IsModified = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_gw_pending_configs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GwClusters",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ClusterId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
LoadBalancingPolicy = table.Column<string>(type: "character varying(50)", maxLength: 50, 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),
HealthCheck = table.Column<string>(type: "jsonb", nullable: true),
SessionAffinity = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GwClusters", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GwDestination",
columns: table => new
{
ClusterId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
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<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Weight = table.Column<int>(type: "integer", nullable: false),
HealthStatus = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GwDestination", x => new { x.ClusterId, x.Id });
table.ForeignKey(
name: "FK_GwDestination_GwClusters_ClusterId",
column: x => x.ClusterId,
principalTable: "GwClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_gw_pending_configs_ServiceName_Status",
table: "gw_pending_configs",
columns: new[] { "ServiceName", "Status" });
migrationBuilder.CreateIndex(
name: "IX_gw_pending_configs_Source",
table: "gw_pending_configs",
column: "Source");
migrationBuilder.CreateIndex(
name: "IX_gw_pending_configs_Status",
table: "gw_pending_configs",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_gw_pending_configs_TenantCode_Status",
table: "gw_pending_configs",
columns: new[] { "TenantCode", "Status" });
migrationBuilder.CreateIndex(
name: "IX_gw_pending_configs_Type",
table: "gw_pending_configs",
column: "Type");
migrationBuilder.CreateIndex(
name: "IX_GwClusters_ClusterId",
table: "GwClusters",
column: "ClusterId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GwClusters_Name",
table: "GwClusters",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_GwClusters_Status",
table: "GwClusters",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_GwDestination_ClusterId_DestinationId",
table: "GwDestination",
columns: new[] { "ClusterId", "DestinationId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gw_pending_configs");
migrationBuilder.DropTable(
name: "GwDestination");
migrationBuilder.DropTable(
name: "GwClusters");
migrationBuilder.DropColumn(
name: "AuthorizationPolicy",
table: "gw_tenant_routes");
migrationBuilder.DropColumn(
name: "CorsPolicy",
table: "gw_tenant_routes");
migrationBuilder.DropColumn(
name: "LoadBalancingPolicy",
table: "gw_tenant_routes");
migrationBuilder.DropColumn(
name: "RateLimiterPolicy",
table: "gw_tenant_routes");
migrationBuilder.DropColumn(
name: "TimeoutSeconds",
table: "gw_tenant_routes");
migrationBuilder.AddColumn<string>(
name: "PathPattern",
table: "gw_tenant_routes",
type: "character varying(200)",
maxLength: 200,
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "gw_service_instances",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Address = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ClusterId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DestinationId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Health = table.Column<int>(type: "integer", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true),
UpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Version = table.Column<int>(type: "integer", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_gw_service_instances", x => x.Id);
});
migrationBuilder.CreateTable(
name: "gw_tenants",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedBy = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
TenantCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
TenantName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true),
UpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Version = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_gw_tenants", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_gw_service_instances_ClusterId_DestinationId",
table: "gw_service_instances",
columns: new[] { "ClusterId", "DestinationId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_gw_service_instances_Health",
table: "gw_service_instances",
column: "Health");
migrationBuilder.CreateIndex(
name: "IX_gw_tenants_TenantCode",
table: "gw_tenants",
column: "TenantCode",
unique: true);
}
}
}

View File

@ -23,16 +23,80 @@ namespace Fengling.Console.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwServiceInstance", b =>
modelBuilder.Entity("Fengling.Console.Models.Entities.PendingConfig", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ConfigJson")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConfirmedBy")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsModified")
.HasColumnType("boolean");
b.Property<bool>("IsNew")
.HasColumnType("boolean");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Source");
b.HasIndex("Status");
b.HasIndex("Type");
b.HasIndex("ServiceName", "Status");
b.HasIndex("TenantCode", "Status");
b.ToTable("gw_pending_configs", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
@ -44,72 +108,26 @@ namespace Fengling.Console.Migrations
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<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
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("gw_service_instances", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.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")
b.Property<string>("LoadBalancingPolicy")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TenantName")
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
@ -121,10 +139,14 @@ namespace Fengling.Console.Migrations
b.HasKey("Id");
b.HasIndex("TenantCode")
b.HasIndex("ClusterId")
.IsUnique();
b.ToTable("gw_tenants", (string)null);
b.HasIndex("Name");
b.HasIndex("Status");
b.ToTable("GwClusters");
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwTenantRoute", b =>
@ -132,11 +154,19 @@ namespace Fengling.Console.Migrations
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AuthorizationPolicy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("CorsPolicy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
@ -149,14 +179,17 @@ namespace Fengling.Console.Migrations
b.Property<bool>("IsGlobal")
.HasColumnType("boolean");
b.Property<string>("PathPattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("LoadBalancingPolicy")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("RateLimiterPolicy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
@ -170,6 +203,9 @@ namespace Fengling.Console.Migrations
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("TimeoutSeconds")
.HasColumnType("integer");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
@ -645,6 +681,104 @@ namespace Fengling.Console.Migrations
b.ToTable("idn_user_tokens", (string)null);
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b =>
{
b.OwnsMany("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwDestination", "Destinations", b1 =>
{
b1.Property<string>("ClusterId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b1.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b1.Property<string>("Health")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b1.Property<int>("HealthStatus")
.HasColumnType("integer");
b1.Property<int>("Status")
.HasColumnType("integer");
b1.Property<int>("Weight")
.HasColumnType("integer");
b1.HasKey("ClusterId", "Id");
b1.HasIndex("ClusterId", "DestinationId");
b1.ToTable("GwDestination");
b1.WithOwner()
.HasForeignKey("ClusterId");
});
b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwHealthCheckConfig", "HealthCheck", b1 =>
{
b1.Property<string>("GwClusterId");
b1.Property<bool>("Enabled");
b1.Property<int>("IntervalSeconds");
b1.Property<string>("Path");
b1.Property<int>("TimeoutSeconds");
b1.HasKey("GwClusterId");
b1.ToTable("GwClusters");
b1.ToJson("HealthCheck");
b1.WithOwner()
.HasForeignKey("GwClusterId");
});
b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwSessionAffinityConfig", "SessionAffinity", b1 =>
{
b1.Property<string>("GwClusterId");
b1.Property<string>("AffinityKeyName")
.IsRequired();
b1.Property<bool>("Enabled");
b1.Property<string>("Policy")
.IsRequired();
b1.HasKey("GwClusterId");
b1.ToTable("GwClusters");
b1.ToJson("SessionAffinity");
b1.WithOwner()
.HasForeignKey("GwClusterId");
});
b.Navigation("Destinations");
b.Navigation("HealthCheck");
b.Navigation("SessionAffinity");
});
modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", b =>
{
b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.TenantAggregate.TenantInfo", "TenantInfo", b1 =>

View File

@ -0,0 +1,78 @@
namespace Fengling.Console.Models.Entities;
/// <summary>
/// 待确认配置实体
/// 用于存储从 K8s 服务发现或手动添加的待确认网关配置
/// </summary>
public class PendingConfig
{
/// <summary>
/// 唯一标识
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// 配置类型Route, Cluster, Destination
/// </summary>
public PendingConfigType Type { get; set; }
/// <summary>
/// 配置来源K8sDiscovery, Manual
/// </summary>
public PendingConfigSource Source { get; set; }
/// <summary>
/// 来源标识K8s Service UID 或手动标识)
/// </summary>
public string SourceId { get; set; } = "";
/// <summary>
/// 配置内容JSON 格式)
/// </summary>
public string ConfigJson { get; set; } = "";
/// <summary>
/// 服务名称
/// </summary>
public string ServiceName { get; set; } = "";
/// <summary>
/// 租户编码
/// </summary>
public string? TenantCode { get; set; }
/// <summary>
/// 集群ID
/// </summary>
public string ClusterId { get; set; } = "";
/// <summary>
/// 配置状态
/// </summary>
public PendingConfigStatus Status { get; set; } = PendingConfigStatus.Pending;
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 确认时间
/// </summary>
public DateTime? ConfirmedAt { get; set; }
/// <summary>
/// 确认人
/// </summary>
public string? ConfirmedBy { get; set; }
/// <summary>
/// 是否为新配置
/// </summary>
public bool IsNew { get; set; }
/// <summary>
/// 是否已修改
/// </summary>
public bool IsModified { get; set; }
}

View File

@ -0,0 +1,64 @@
namespace Fengling.Console.Models.Entities;
/// <summary>
/// 待确认配置类型
/// </summary>
public enum PendingConfigType
{
/// <summary>
/// 路由配置
/// </summary>
Route = 0,
/// <summary>
/// 集群配置
/// </summary>
Cluster = 1,
/// <summary>
/// 目标地址配置
/// </summary>
Destination = 2
}
/// <summary>
/// 待确认配置来源
/// </summary>
public enum PendingConfigSource
{
/// <summary>
/// K8s 服务发现
/// </summary>
K8sDiscovery = 0,
/// <summary>
/// 手动创建
/// </summary>
Manual = 1
}
/// <summary>
/// 待确认配置状态
/// </summary>
public enum PendingConfigStatus
{
/// <summary>
/// 待确认
/// </summary>
Pending = 0,
/// <summary>
/// 已确认
/// </summary>
Confirmed = 1,
/// <summary>
/// 已拒绝
/// </summary>
Rejected = 2,
/// <summary>
/// 已修改
/// </summary>
Modified = 3
}

View File

@ -12,6 +12,7 @@ using Microsoft.IdentityModel.Tokens;
using System.Text;
using NetCorePal.Extensions.DependencyInjection;
using OpenIddict.Validation.AspNetCore;
using k8s;
var builder = WebApplication.CreateBuilder(args);
@ -28,16 +29,6 @@ builder.Services.AddDbContext<ConsoleDbContext>(options =>
}
options.EnableDetailedErrors();
});
builder.Services.AddDbContext<PlatformDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
}
options.EnableDetailedErrors();
});
// Use Platform's identity
builder.Services.AddIdentity<ApplicationUser, ApplicationRole>()
@ -67,7 +58,6 @@ builder.Services.AddScoped<IH5LinkService, H5LinkService>();
builder.Services.AddOpenIddict()
.AddCore(options => { options.UseEntityFrameworkCore().UseDbContext<ConsoleDbContext>(); })
.AddCore(options => { options.UseEntityFrameworkCore().UseDbContext<PlatformDbContext>(); })
.AddValidation(options =>
{
options.SetIssuer("http://localhost:5132/");
@ -105,7 +95,9 @@ builder.Services.AddSwaggerGen(c =>
c.CustomSchemaIds(type => type.FullName);
});
builder.Services.AddRepositories(typeof(ConsoleDbContext).Assembly);
// 添加 K8s 服务监视
builder.Services.AddK8sServiceWatch();
var app = builder.Build();

View File

@ -0,0 +1,427 @@
using System.Threading.Channels;
using k8s;
using k8s.Models;
namespace Fengling.Console.Services;
/// <summary>
/// Kubernetes 服务变更事件
/// </summary>
public sealed record K8sServiceEvent
{
/// <summary>
/// 事件类型Added, Modified, Deleted, Error, Bookmark
/// </summary>
public required WatchEventType EventType { get; init; }
/// <summary>
/// 服务对象
/// </summary>
public required V1Service Service { get; init; }
/// <summary>
/// 事件发生时间
/// </summary>
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
}
/// <summary>
/// Kubernetes 服务监视服务
/// 监视 K8s Service 资源变化并发布通知
/// </summary>
public sealed class K8sServiceWatchService : BackgroundService, IDisposable
{
private readonly ILogger<K8sServiceWatchService> _logger;
private readonly IKubernetes _kubernetesClient;
private readonly INotificationService _notificationService;
private readonly K8sServiceWatchOptions _options;
// 用于通知新事件的通道
private readonly Channel<K8sServiceEvent> _eventChannel;
// 取消令牌源,用于控制重连循环
private CancellationTokenSource? _watchCts;
// 当前退避延迟
private TimeSpan _currentBackoff;
// 是否已释放
private bool _disposed;
public K8sServiceWatchService(
ILogger<K8sServiceWatchService> logger,
IKubernetes kubernetesClient,
INotificationService notificationService,
IConfiguration configuration)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_kubernetesClient = kubernetesClient ?? throw new ArgumentNullException(nameof(kubernetesClient));
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_options = new K8sServiceWatchOptions();
configuration.GetSection("K8sServiceWatch").Bind(_options);
// 创建有界通道,防止内存无限增长
var channelOptions = new BoundedChannelOptions(_options.ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest
};
_eventChannel = Channel.CreateBounded<K8sServiceEvent>(channelOptions);
_currentBackoff = _options.InitialBackoff;
}
/// <summary>
/// 主执行循环
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("K8sServiceWatchService 启动,开始监视 Service 资源...");
// 启动事件处理任务
var processTask = ProcessEventsAsync(stoppingToken);
try
{
// 主重连循环
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RunWatchLoopAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Watch 循环因服务停止而取消");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Watch 循环发生错误,将在 {BackoffMs}ms 后重试", _currentBackoff.TotalMilliseconds);
await WaitWithBackoffAsync(stoppingToken);
}
}
}
finally
{
// 确保事件处理任务也停止
_eventChannel.Writer.TryComplete();
try
{
await processTask.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
}
catch (TimeoutException)
{
_logger.LogWarning("事件处理任务未在 5 秒内完成");
}
catch (Exception ex)
{
_logger.LogError(ex, "等待事件处理任务时发生错误");
}
}
_logger.LogInformation("K8sServiceWatchService 已停止");
}
/// <summary>
/// 运行单次 Watch 循环
/// </summary>
private async Task RunWatchLoopAsync(CancellationToken stoppingToken)
{
// 创建本次 Watch 的取消令牌源
_watchCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
var watchToken = _watchCts.Token;
_logger.LogInformation("开始 Watch Kubernetes Services (命名空间: {Namespace})",
string.IsNullOrEmpty(_options.Namespace) ? "全部" : _options.Namespace);
try
{
// 构建标签选择器
var labelSelector = _options.LabelSelector;
// 启动 Watch
var responseTask = string.IsNullOrEmpty(_options.Namespace)
? _kubernetesClient.CoreV1.ListServiceForAllNamespacesWithHttpMessagesAsync(
watch: true,
labelSelector: labelSelector,
cancellationToken: watchToken)
: _kubernetesClient.CoreV1.ListNamespacedServiceWithHttpMessagesAsync(
namespaceParameter: _options.Namespace,
watch: true,
labelSelector: labelSelector,
cancellationToken: watchToken);
var response = await responseTask;
// 使用扩展方法 Watch
var watchable = response.Watch<V1Service, V1ServiceList>(
onEvent: (type, service) =>
{
_ = HandleServiceEventAsync(type, service);
},
onError: (ex) =>
{
_logger.LogError(ex, "Watch 发生错误");
},
onClosed: () =>
{
_logger.LogWarning("Watch 连接已关闭");
});
// 等待直到取消
await Task.Delay(Timeout.Infinite, watchToken);
}
catch (OperationCanceledException) when (watchToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Watch 过程中发生异常");
throw;
}
finally
{
_watchCts?.Cancel();
_watchCts?.Dispose();
_watchCts = null;
}
}
/// <summary>
/// 处理 Service 事件
/// </summary>
private async Task HandleServiceEventAsync(WatchEventType type, V1Service service)
{
try
{
var @event = new K8sServiceEvent
{
EventType = type,
Service = service
};
// 尝试写入通道,不阻塞
await _eventChannel.Writer.WriteAsync(@event);
_logger.LogDebug("Service 事件已入队: {Name}/{Namespace}, 类型: {Type}",
service.Metadata?.Name,
service.Metadata?.NamespaceProperty,
type);
}
catch (ChannelClosedException)
{
_logger.LogWarning("事件通道已关闭,无法写入事件");
}
catch (Exception ex)
{
_logger.LogError(ex, "处理 Service 事件时发生错误");
}
}
/// <summary>
/// 处理事件队列
/// </summary>
private async Task ProcessEventsAsync(CancellationToken stoppingToken)
{
await foreach (var @event in _eventChannel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessSingleEventAsync(@event, stoppingToken);
// 成功处理后重置退避
ResetBackoff();
}
catch (Exception ex)
{
_logger.LogError(ex, "处理单个事件时发生错误");
}
}
}
/// <summary>
/// 处理单个事件
/// </summary>
private async Task ProcessSingleEventAsync(K8sServiceEvent @event, CancellationToken cancellationToken)
{
var service = @event.Service;
var metadata = service.Metadata;
if (metadata == null)
{
_logger.LogWarning("收到无效的 Service 事件Metadata 为空");
return;
}
var eventAction = @event.EventType switch
{
WatchEventType.Added => "create",
WatchEventType.Modified => "update",
WatchEventType.Deleted => "delete",
_ => "unknown"
};
_logger.LogInformation("Service {Action}: {Name}/{Namespace}",
eventAction,
metadata.Name,
metadata.NamespaceProperty);
// 发布配置变更通知
var details = new
{
ServiceName = metadata.Name,
Namespace = metadata.NamespaceProperty,
ClusterIP = service.Spec?.ClusterIP,
ExternalIPs = service.Spec?.ExternalIPs,
Ports = service.Spec?.Ports?.Select(p => new { p.Name, p.Port, p.TargetPort, p.Protocol }),
Labels = metadata.Labels,
EventTimestamp = @event.Timestamp
};
await _notificationService.PublishConfigChangeAsync(
"service",
eventAction,
details,
cancellationToken);
}
/// <summary>
/// 带指数退避的等待
/// </summary>
private async Task WaitWithBackoffAsync(CancellationToken cancellationToken)
{
try
{
await Task.Delay(_currentBackoff, cancellationToken);
}
catch (OperationCanceledException)
{
// 正常取消,不做处理
}
// 增加退避时间
_currentBackoff = TimeSpan.FromMilliseconds(
Math.Min(
_currentBackoff.TotalMilliseconds * _options.BackoffMultiplier,
_options.MaxBackoff.TotalMilliseconds));
}
/// <summary>
/// 重置退避时间
/// </summary>
private void ResetBackoff()
{
_currentBackoff = _options.InitialBackoff;
}
/// <summary>
/// 停止服务
/// </summary>
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("正在停止 K8sServiceWatchService...");
// 取消当前的 Watch
_watchCts?.Cancel();
// 关闭事件通道
_eventChannel.Writer.TryComplete();
await base.StopAsync(cancellationToken);
}
/// <summary>
/// 释放资源
/// </summary>
public override void Dispose()
{
if (_disposed)
{
return;
}
_watchCts?.Cancel();
_watchCts?.Dispose();
_eventChannel.Writer.TryComplete();
_disposed = true;
base.Dispose();
GC.SuppressFinalize(this);
}
}
/// <summary>
/// K8s 服务监视配置选项
/// </summary>
public sealed class K8sServiceWatchOptions
{
/// <summary>
/// 要监视的命名空间,空字符串表示监视所有命名空间
/// </summary>
public string Namespace { get; set; } = "";
/// <summary>
/// 标签选择器,用于过滤 Service
/// </summary>
public string? LabelSelector { get; set; }
/// <summary>
/// 初始退避时间
/// </summary>
public TimeSpan InitialBackoff { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// 最大退避时间
/// </summary>
public TimeSpan MaxBackoff { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// 退避乘数
/// </summary>
public double BackoffMultiplier { get; set; } = 2.0;
/// <summary>
/// 事件通道容量
/// </summary>
public int ChannelCapacity { get; set; } = 1000;
}
/// <summary>
/// 服务扩展
/// </summary>
public static class K8sServiceWatchServiceExtensions
{
/// <summary>
/// 添加 K8s 服务监视服务
/// </summary>
public static IServiceCollection AddK8sServiceWatch(this IServiceCollection services)
{
services.AddSingleton<IKubernetes>(provider =>
{
var logger = provider.GetRequiredService<ILogger<IKubernetes>>();
try
{
// 尝试加载集群内配置
var config = KubernetesClientConfiguration.InClusterConfig();
logger.LogInformation("使用集群内 Kubernetes 配置");
return new Kubernetes(config);
}
catch (Exception ex)
{
logger.LogWarning(ex, "无法加载集群内配置,尝试加载本地 kubeconfig");
// 回退到本地 kubeconfig
var config = KubernetesClientConfiguration.BuildConfigFromConfigFile();
logger.LogInformation("使用本地 Kubernetes 配置");
return new Kubernetes(config);
}
});
services.AddHostedService<K8sServiceWatchService>();
return services;
}
}