Compare commits

...

4 Commits

Author SHA1 Message Date
Kimi CLI
b66b231917 refactor: replace GwTenantRoute with GwRoute, change Id type to string
All checks were successful
Publish Platform NuGet Packages / build (push) Successful in 26s
- Remove GwTenantRoute (old tenant-specific route entity)
- Add GwRoute with string Id (Guid.CreateVersion7)
- Update IRouteManager and IRouteStore interfaces
- Update PlatformDbContext configuration for new schema
- GwRoute is now global, tenant-specific routing moved to GwDestination.TenantCode

BREAKING CHANGE: Database schema change requires table recreation
2026-03-08 15:21:43 +08:00
movingsam
61c18916eb chore: restore version to 1.0.0 (version managed by git tag)
All checks were successful
Publish Platform NuGet Packages / build (push) Successful in 1m38s
恢复版本号为 1.0.0,实际发布版本将通过 Git Tag 触发 CI/CD 确定
2026-03-08 00:44:50 +08:00
movingsam
021f464c0d feat: 添加 GwDestination 租户代码属性并更新版本至 1.0.1
Some checks are pending
Publish Platform NuGet Packages / build (push) Waiting to run
- 在 GwDestination 实体添加 TenantCode 属性,用于区分租户专属目标
  - null 或空字符串表示默认目标(所有租户共享)
  - 有值表示该目标专属于指定租户
- 更新 Fengling.Platform.Domain 版本号从 1.0.0 到 1.0.1
2026-03-08 00:43:21 +08:00
movingsam
b9bf925c45 fix(efcore): 修复 EF Core 10 JSON 映射兼容性问题
Some checks are pending
Publish Platform NuGet Packages / build (push) Waiting to run
修复在 EF Core 10 中使用 JSON 值对象时出现的映射错误:

## 问题
在 EF Core 10 中,GwRouteMatch 类的嵌套集合属性(Headers 和 QueryParameters)
导致 "Unable to determine the relationship" 错误。

## 解决方案
1. 在 PlatformDbContext 中使用 modelBuilder.Ignore<> 忽略相关类型
2. 将 OwnsOne().ToJson() 配置改为使用值转换器(Value Converter)
   将对象序列化为 JSON 字符串存储到 jsonb 列
3. 在 GwRouteMatch 类的 Headers 和 QueryParameters 属性上添加 [NotMapped] 特性
4. 添加 [JsonInclude] 特性确保序列化包含这些属性

## 技术细节
- 使用 HasColumnType("jsonb") 存储 JSON 数据
- 使用值转换器处理对象序列化/反序列化
- 保持与 PostgreSQL jsonb 类型的兼容性

## 文件变更
- 修改: Fengling.Platform.Domain/AggregatesModel/GatewayAggregate/GwRouteMatch.cs
- 修改: Fengling.Platform.Infrastructure/PlatformDbContext.cs

关联任务: IMPL-4 (EF Core 兼容性修复)
关联重构计划: WFS-gateway-refactor
2026-03-08 00:32:45 +08:00
9 changed files with 85 additions and 72 deletions

View File

@ -34,4 +34,11 @@ public class GwDestination
/// 状态 /// 状态
/// </summary> /// </summary>
public int Status { get; set; } = 1; public int Status { get; set; } = 1;
/// <summary>
/// 租户代码,用于区分租户专属目标
/// null 或空字符串表示默认目标(所有租户共享)
/// 有值表示该目标专属于指定租户
/// </summary>
public string? TenantCode { get; set; }
} }

View File

@ -1,16 +1,12 @@
namespace Fengling.Platform.Domain.AggregatesModel.GatewayAggregate; namespace Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
/// <summary> /// <summary>
/// 网关租户路由实体 - 表示路由规则配置 /// 网关路由实体 - 表示全局路由规则配置
/// </summary> /// </summary>
public class GwTenantRoute public class GwRoute
{ {
public string Id { get; set; } = Guid.CreateVersion7().ToString("N"); public string Id { get; set; } = Guid.CreateVersion7().ToString("N");
/// <summary>
/// 租户代码
/// </summary>
public string TenantCode { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 服务名称 /// 服务名称
@ -67,10 +63,6 @@ public class GwTenantRoute
/// </summary> /// </summary>
public int Status { get; set; } = 1; public int Status { get; set; } = 1;
/// <summary>
/// 是否全局路由
/// </summary>
public bool IsGlobal { get; set; } = false;
/// <summary> /// <summary>
/// 创建人ID /// 创建人ID

View File

@ -1,3 +1,7 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace Fengling.Platform.Domain.AggregatesModel.GatewayAggregate; namespace Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
/// <summary> /// <summary>
@ -24,11 +28,15 @@ public class GwRouteMatch
/// <summary> /// <summary>
/// Header 匹配规则 /// Header 匹配规则
/// </summary> /// </summary>
[NotMapped]
[JsonInclude]
public List<GwRouteHeader>? Headers { get; set; } public List<GwRouteHeader>? Headers { get; set; }
/// <summary> /// <summary>
/// 查询参数匹配规则 /// 查询参数匹配规则
/// </summary> /// </summary>
[NotMapped]
[JsonInclude]
public List<GwRouteQueryParameter>? QueryParameters { get; set; } public List<GwRouteQueryParameter>? QueryParameters { get; set; }
} }

View File

@ -6,6 +6,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems> <EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
<Version>1.0.0</Version>
<PackageVersion>1.0.0</PackageVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -9,10 +9,9 @@ namespace Fengling.Platform.Infrastructure;
/// </summary> /// </summary>
public interface IRouteManager public interface IRouteManager
{ {
Task<GwTenantRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default); Task<GwRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default);
Task<GwTenantRoute?> FindByTenantCodeAsync(string tenantCode, CancellationToken cancellationToken = default); Task<IList<GwRoute>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IList<GwTenantRoute>> GetAllAsync(CancellationToken cancellationToken = default); Task<IdentityResult> CreateRouteAsync(GwRoute route, CancellationToken cancellationToken = default);
Task<IdentityResult> CreateRouteAsync(GwTenantRoute route, CancellationToken cancellationToken = default); Task<IdentityResult> UpdateRouteAsync(GwRoute route, CancellationToken cancellationToken = default);
Task<IdentityResult> UpdateRouteAsync(GwTenantRoute route, CancellationToken cancellationToken = default); Task<IdentityResult> DeleteRouteAsync(GwRoute route, CancellationToken cancellationToken = default);
Task<IdentityResult> DeleteRouteAsync(GwTenantRoute route, CancellationToken cancellationToken = default);
} }

View File

@ -9,15 +9,14 @@ namespace Fengling.Platform.Infrastructure;
/// </summary> /// </summary>
public interface IRouteStore public interface IRouteStore
{ {
Task<GwTenantRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default); Task<GwRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default);
Task<GwTenantRoute?> FindByTenantCodeAsync(string tenantCode, CancellationToken cancellationToken = default); Task<GwRoute?> FindByClusterIdAsync(string clusterId, CancellationToken cancellationToken = default);
Task<GwTenantRoute?> FindByClusterIdAsync(string clusterId, CancellationToken cancellationToken = default); Task<IList<GwRoute>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IList<GwTenantRoute>> GetAllAsync(CancellationToken cancellationToken = default); Task<IList<GwRoute>> GetPagedAsync(int page, int pageSize,
Task<IList<GwTenantRoute>> GetPagedAsync(int page, int pageSize, string? tenantCode = null,
string? serviceName = null, RouteStatus? status = null, CancellationToken cancellationToken = default); string? serviceName = null, RouteStatus? status = null, CancellationToken cancellationToken = default);
Task<int> GetCountAsync(string? tenantCode = null, string? serviceName = null, Task<int> GetCountAsync(string? serviceName = null,
RouteStatus? status = null, CancellationToken cancellationToken = default); RouteStatus? status = null, CancellationToken cancellationToken = default);
Task<IdentityResult> CreateAsync(GwTenantRoute route, CancellationToken cancellationToken = default); Task<IdentityResult> CreateAsync(GwRoute route, CancellationToken cancellationToken = default);
Task<IdentityResult> UpdateAsync(GwTenantRoute route, CancellationToken cancellationToken = default); Task<IdentityResult> UpdateAsync(GwRoute route, CancellationToken cancellationToken = default);
Task<IdentityResult> DeleteAsync(GwTenantRoute route, CancellationToken cancellationToken = default); Task<IdentityResult> DeleteAsync(GwRoute route, CancellationToken cancellationToken = default);
} }

View File

@ -4,6 +4,9 @@ using Fengling.Platform.Domain.AggregatesModel.TenantAggregate;
using Fengling.Platform.Domain.AggregatesModel.UserAggregate; using Fengling.Platform.Domain.AggregatesModel.UserAggregate;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Text.Json;
namespace Fengling.Platform.Infrastructure; namespace Fengling.Platform.Infrastructure;
@ -15,7 +18,7 @@ public class PlatformDbContext(DbContextOptions options)
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
// Gateway 实体 // Gateway 实体
public DbSet<GwTenantRoute> GwTenantRoutes => Set<GwTenantRoute>(); public DbSet<GwRoute> GwRoutes => Set<GwRoute>();
public DbSet<GwCluster> GwClusters => Set<GwCluster>(); public DbSet<GwCluster> GwClusters => Set<GwCluster>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@ -25,6 +28,12 @@ public class PlatformDbContext(DbContextOptions options)
throw new ArgumentNullException(nameof(modelBuilder)); throw new ArgumentNullException(nameof(modelBuilder));
} }
// 忽略这些类型,让它们只作为 JSON 值对象使用
modelBuilder.Ignore<GwRouteMatch>();
modelBuilder.Ignore<GwRouteHeader>();
modelBuilder.Ignore<GwRouteQueryParameter>();
modelBuilder.Ignore<GwTransform>();
modelBuilder.Entity<ApplicationUser>(entity => modelBuilder.Entity<ApplicationUser>(entity =>
{ {
entity.Property(e => e.PhoneNumber).HasMaxLength(20); entity.Property(e => e.PhoneNumber).HasMaxLength(20);
@ -82,10 +91,10 @@ public class PlatformDbContext(DbContextOptions options)
}); });
// Gateway 实体配置 // Gateway 实体配置
modelBuilder.Entity<GwTenantRoute>(entity => modelBuilder.Entity<GwRoute>(entity =>
{ {
entity.ToTable("GwRoutes");
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.Property(e => e.TenantCode).HasMaxLength(50);
entity.Property(e => e.ServiceName).HasMaxLength(100).IsRequired(); entity.Property(e => e.ServiceName).HasMaxLength(100).IsRequired();
entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired(); entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired();
entity.Property(e => e.AuthorizationPolicy).HasMaxLength(100); entity.Property(e => e.AuthorizationPolicy).HasMaxLength(100);
@ -100,27 +109,38 @@ public class PlatformDbContext(DbContextOptions options)
) )
.HasMaxLength(50); .HasMaxLength(50);
// 值对象映射为 JSON 列 // 值对象映射为 JSON 列 - 使用值转换器
entity.OwnsOne(e => e.Match, navigationBuilder => var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
{ entity.Property(e => e.Match)
navigationBuilder.ToJson(); .HasConversion(
}); v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<GwRouteMatch>(v, jsonOptions)!,
new ValueComparer<GwRouteMatch>(
(c1, c2) => JsonSerializer.Serialize(c1, jsonOptions) == JsonSerializer.Serialize(c2, jsonOptions),
c => c == null ? 0 : JsonSerializer.Serialize(c, jsonOptions).GetHashCode(),
c => JsonSerializer.Deserialize<GwRouteMatch>(JsonSerializer.Serialize(c, jsonOptions), jsonOptions)!))
.HasColumnType("jsonb");
// 转换规则映射为 JSON 列 // 转换规则映射为 JSON 列 - 使用值转换器
entity.OwnsMany(e => e.Transforms, navigationBuilder => entity.Property(e => e.Transforms)
{ .HasConversion(
navigationBuilder.ToJson(); v => JsonSerializer.Serialize(v, jsonOptions),
}); v => JsonSerializer.Deserialize<List<GwTransform>>(v, jsonOptions),
new ValueComparer<List<GwTransform>>(
(c1, c2) => JsonSerializer.Serialize(c1, jsonOptions) == JsonSerializer.Serialize(c2, jsonOptions),
c => c == null ? 0 : JsonSerializer.Serialize(c, jsonOptions).GetHashCode(),
c => JsonSerializer.Deserialize<List<GwTransform>>(JsonSerializer.Serialize(c, jsonOptions), jsonOptions)!))
.HasColumnType("jsonb");
entity.HasIndex(e => e.TenantCode);
entity.HasIndex(e => e.ServiceName); entity.HasIndex(e => e.ServiceName);
entity.HasIndex(e => e.ClusterId); entity.HasIndex(e => e.ClusterId);
entity.HasIndex(e => new { e.ServiceName, e.IsGlobal, e.Status }); entity.HasIndex(e => new { e.ServiceName, e.Status });
}); });
// GwCluster 聚合根配置 // GwCluster 聚合根配置
modelBuilder.Entity<GwCluster>(entity => modelBuilder.Entity<GwCluster>(entity =>
{ {
entity.ToTable("ServiceInstances");
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired(); entity.Property(e => e.ClusterId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Name).HasMaxLength(100).IsRequired(); entity.Property(e => e.Name).HasMaxLength(100).IsRequired();

View File

@ -15,24 +15,21 @@ public class RouteManager : IRouteManager
_store = store; _store = store;
} }
public virtual Task<GwTenantRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default) public virtual Task<GwRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default)
=> _store.FindByIdAsync(id, cancellationToken); => _store.FindByIdAsync(id, cancellationToken);
public virtual Task<GwTenantRoute?> FindByTenantCodeAsync(string tenantCode, CancellationToken cancellationToken = default) public virtual Task<IList<GwRoute>> GetAllAsync(CancellationToken cancellationToken = default)
=> _store.FindByTenantCodeAsync(tenantCode, cancellationToken);
public virtual Task<IList<GwTenantRoute>> GetAllAsync(CancellationToken cancellationToken = default)
=> _store.GetAllAsync(cancellationToken); => _store.GetAllAsync(cancellationToken);
public virtual Task<IdentityResult> CreateRouteAsync(GwTenantRoute route, CancellationToken cancellationToken = default) public virtual Task<IdentityResult> CreateRouteAsync(GwRoute route, CancellationToken cancellationToken = default)
{ {
route.CreatedTime = DateTime.UtcNow; route.CreatedTime = DateTime.UtcNow;
return _store.CreateAsync(route, cancellationToken); return _store.CreateAsync(route, cancellationToken);
} }
public virtual Task<IdentityResult> UpdateRouteAsync(GwTenantRoute route, CancellationToken cancellationToken = default) public virtual Task<IdentityResult> UpdateRouteAsync(GwRoute route, CancellationToken cancellationToken = default)
=> _store.UpdateAsync(route, cancellationToken); => _store.UpdateAsync(route, cancellationToken);
public virtual Task<IdentityResult> DeleteRouteAsync(GwTenantRoute route, CancellationToken cancellationToken = default) public virtual Task<IdentityResult> DeleteRouteAsync(GwRoute route, CancellationToken cancellationToken = default)
=> _store.DeleteAsync(route, cancellationToken); => _store.DeleteAsync(route, cancellationToken);
} }

View File

@ -11,45 +11,37 @@ public class RouteStore<TContext> : IRouteStore
where TContext : PlatformDbContext where TContext : PlatformDbContext
{ {
private readonly TContext _context; private readonly TContext _context;
private readonly DbSet<GwTenantRoute> _routes; private readonly DbSet<GwRoute> _routes;
public RouteStore(TContext context) public RouteStore(TContext context)
{ {
_context = context; _context = context;
_routes = context.GwTenantRoutes; _routes = context.GwRoutes;
} }
public void Dispose() { } public void Dispose() { }
public virtual Task<GwTenantRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default) public virtual Task<GwRoute?> FindByIdAsync(string? id, CancellationToken cancellationToken = default)
{ {
if (id == null) return Task.FromResult<GwTenantRoute?>(null); if (id == null) return Task.FromResult<GwRoute?>(null);
return _routes.FirstOrDefaultAsync(r => r.Id == id, cancellationToken); return _routes.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
} }
public virtual Task<GwTenantRoute?> FindByTenantCodeAsync(string tenantCode, CancellationToken cancellationToken = default) public virtual Task<GwRoute?> FindByClusterIdAsync(string clusterId, CancellationToken cancellationToken = default)
{
return _routes.FirstOrDefaultAsync(r => r.TenantCode == tenantCode && !r.IsDeleted, cancellationToken);
}
public virtual Task<GwTenantRoute?> FindByClusterIdAsync(string clusterId, CancellationToken cancellationToken = default)
{ {
return _routes.FirstOrDefaultAsync(r => r.ClusterId == clusterId && !r.IsDeleted, cancellationToken); return _routes.FirstOrDefaultAsync(r => r.ClusterId == clusterId && !r.IsDeleted, cancellationToken);
} }
public virtual async Task<IList<GwTenantRoute>> GetAllAsync(CancellationToken cancellationToken = default) public virtual async Task<IList<GwRoute>> GetAllAsync(CancellationToken cancellationToken = default)
{ {
return await _routes.Where(r => !r.IsDeleted).ToListAsync(cancellationToken); return await _routes.Where(r => !r.IsDeleted).ToListAsync(cancellationToken);
} }
public virtual async Task<IList<GwTenantRoute>> GetPagedAsync(int page, int pageSize, string? tenantCode = null, public virtual async Task<IList<GwRoute>> GetPagedAsync(int page, int pageSize,
string? serviceName = null, RouteStatus? status = null, CancellationToken cancellationToken = default) string? serviceName = null, RouteStatus? status = null, CancellationToken cancellationToken = default)
{ {
var query = _routes.AsQueryable(); var query = _routes.AsQueryable();
if (!string.IsNullOrEmpty(tenantCode))
query = query.Where(r => r.TenantCode.Contains(tenantCode));
if (!string.IsNullOrEmpty(serviceName)) if (!string.IsNullOrEmpty(serviceName))
query = query.Where(r => r.ServiceName.Contains(serviceName)); query = query.Where(r => r.ServiceName.Contains(serviceName));
@ -64,14 +56,11 @@ public class RouteStore<TContext> : IRouteStore
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public virtual async Task<int> GetCountAsync(string? tenantCode = null, string? serviceName = null, public virtual async Task<int> GetCountAsync(string? serviceName = null,
RouteStatus? status = null, CancellationToken cancellationToken = default) RouteStatus? status = null, CancellationToken cancellationToken = default)
{ {
var query = _routes.AsQueryable(); var query = _routes.AsQueryable();
if (!string.IsNullOrEmpty(tenantCode))
query = query.Where(r => r.TenantCode.Contains(tenantCode));
if (!string.IsNullOrEmpty(serviceName)) if (!string.IsNullOrEmpty(serviceName))
query = query.Where(r => r.ServiceName.Contains(serviceName)); query = query.Where(r => r.ServiceName.Contains(serviceName));
@ -81,14 +70,14 @@ public class RouteStore<TContext> : IRouteStore
return await query.Where(r => !r.IsDeleted).CountAsync(cancellationToken); return await query.Where(r => !r.IsDeleted).CountAsync(cancellationToken);
} }
public virtual async Task<IdentityResult> CreateAsync(GwTenantRoute route, CancellationToken cancellationToken = default) public virtual async Task<IdentityResult> CreateAsync(GwRoute route, CancellationToken cancellationToken = default)
{ {
_routes.Add(route); _routes.Add(route);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return IdentityResult.Success; return IdentityResult.Success;
} }
public virtual async Task<IdentityResult> UpdateAsync(GwTenantRoute route, CancellationToken cancellationToken = default) public virtual async Task<IdentityResult> UpdateAsync(GwRoute route, CancellationToken cancellationToken = default)
{ {
route.UpdatedTime = DateTime.UtcNow; route.UpdatedTime = DateTime.UtcNow;
_routes.Update(route); _routes.Update(route);
@ -96,7 +85,7 @@ public class RouteStore<TContext> : IRouteStore
return IdentityResult.Success; return IdentityResult.Success;
} }
public virtual async Task<IdentityResult> DeleteAsync(GwTenantRoute route, CancellationToken cancellationToken = default) public virtual async Task<IdentityResult> DeleteAsync(GwRoute route, CancellationToken cancellationToken = default)
{ {
// 软删除 // 软删除
route.IsDeleted = true; route.IsDeleted = true;