IMPL-5: 实现 PendingConfigController API
All checks were successful
Build and Push Docker / build (push) Successful in 2m39s

- 创建 PendingConfigDtos.cs 包含所有 DTO 类
- 创建 PendingConfigController.cs 实现 CRUD 操作
- 支持列表查询(分页、筛选、搜索)
- 支持确认配置(写入数据库 + 发送 NOTIFY)
- 支持拒绝配置(从缓存移除)
- 支持修改配置(更新缓存并标记状态)
- 添加管理员权限控制
- 集成 Swagger XML 文档
- 在 Program.cs 中注册通知服务
This commit is contained in:
movingsam 2026-03-08 01:19:59 +08:00
parent 0e04bb1690
commit 83085c6dea
4 changed files with 672 additions and 1 deletions

View File

@ -1,5 +1,6 @@
global using OpenIddict.Validation.AspNetCore;
global using OpenIddict.Validation.AspNetCore;
global using Fengling.Console.Models.Dtos;
global using Fengling.Console.Services;
global using Fengling.Platform.Infrastructure;
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Mvc;

View File

@ -0,0 +1,491 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Nodes;
using Fengling.Console.Models.Dtos;
using Fengling.Console.Models.Entities;
using Fengling.Console.Models.K8s;
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
namespace Fengling.Console.Controllers;
/// <summary>
/// 待确认配置管理控制器
/// 提供从 K8s 服务发现或手动添加的待确认网关配置的确认、拒绝、修改等功能
/// </summary>
[ApiController]
[Route("api/console/pending-configs")]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public class PendingConfigController : ControllerBase
{
private readonly PendingConfigCache _pendingConfigCache;
private readonly INotificationService _notificationService;
private readonly IClusterStore _clusterStore;
private readonly IRouteStore _routeStore;
private readonly ILogger<PendingConfigController> _logger;
public PendingConfigController(
PendingConfigCache pendingConfigCache,
INotificationService notificationService,
IClusterStore clusterStore,
IRouteStore routeStore,
ILogger<PendingConfigController> logger)
{
_pendingConfigCache = pendingConfigCache;
_notificationService = notificationService;
_clusterStore = clusterStore;
_routeStore = routeStore;
_logger = logger;
}
/// <summary>
/// 获取待确认配置统计信息
/// </summary>
/// <returns>待确认配置的统计数据,包括总数、新配置数、变更数</returns>
/// <response code="200">成功返回统计数据</response>
/// <response code="500">服务器内部错误</response>
[HttpGet("statistics")]
[Produces("application/json")]
[ProducesResponseType(typeof(PendingConfigStatisticsDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public ActionResult<PendingConfigStatisticsDto> GetStatistics()
{
try
{
var allConfigs = _pendingConfigCache.GetAll().ToList();
var result = new PendingConfigStatisticsDto
{
TotalCount = allConfigs.Count,
NewCount = allConfigs.Count(c => c.IsNew),
ModifiedCount = allConfigs.Count(c => c.IsModified)
};
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending config statistics");
return StatusCode(500, new { message = ex.Message });
}
}
/// <summary>
/// 获取待确认配置列表
/// 支持分页、按状态筛选和按服务名搜索
/// </summary>
/// <param name="request">查询参数,包括分页、状态筛选和服务名搜索</param>
/// <returns>分页的待确认配置列表</returns>
/// <response code="200">成功返回配置列表</response>
/// <response code="500">服务器内部错误</response>
[HttpGet]
[Produces("application/json")]
[ProducesResponseType(typeof(PagedResultDto<PendingConfigDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public ActionResult<PagedResultDto<PendingConfigDto>> GetList([FromQuery] PendingConfigListRequest request)
{
try
{
var query = _pendingConfigCache.GetAll().AsEnumerable();
// 应用状态筛选
if (request.Status.HasValue)
{
query = query.Where(c => c.Status == request.Status.Value);
}
// 应用服务名称搜索
if (!string.IsNullOrWhiteSpace(request.ServiceName))
{
query = query.Where(c => c.ServiceName.Contains(request.ServiceName, StringComparison.OrdinalIgnoreCase));
}
// 应用租户编码筛选
if (!string.IsNullOrWhiteSpace(request.TenantCode))
{
query = query.Where(c => c.TenantCode == request.TenantCode);
}
// 应用来源筛选
if (request.Source.HasValue)
{
query = query.Where(c => c.Source == request.Source.Value);
}
var allItems = query.ToList();
var totalCount = allItems.Count;
// 应用分页
var items = allItems
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(MapToDto)
.ToList();
var result = new PagedResultDto<PendingConfigDto>
{
Items = items,
TotalCount = totalCount,
Page = request.Page,
PageSize = request.PageSize
};
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending config list");
return StatusCode(500, new { message = ex.Message });
}
}
/// <summary>
/// 获取单个待确认配置详情
/// </summary>
/// <param name="id">配置ID</param>
/// <returns>待确认配置的详细信息,包括 JSON 配置内容</returns>
/// <response code="200">成功返回配置详情</response>
/// <response code="404">配置不存在</response>
/// <response code="500">服务器内部错误</response>
[HttpGet("{id}")]
[Produces("application/json")]
[ProducesResponseType(typeof(PendingConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public ActionResult<PendingConfigDto> GetDetail(string id)
{
try
{
var config = FindConfigById(id);
if (config == null)
{
return NotFound(new { message = $"Pending config {id} not found" });
}
return Ok(MapToDto(config));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending config {ConfigId}", id);
return StatusCode(500, new { message = ex.Message });
}
}
/// <summary>
/// 确认待确认配置
/// 将配置写入数据库GwTenantRoute/GwCluster/GwDestination发送 PostgreSQL NOTIFY 通知,并从缓存中移除
/// </summary>
/// <param name="id">配置ID</param>
/// <param name="request">确认请求,包含可选的备注信息</param>
/// <returns>操作结果</returns>
/// <response code="200">成功确认配置</response>
/// <response code="404">配置不存在</response>
/// <response code="400">配置格式无效或保存失败</response>
/// <response code="500">服务器内部错误</response>
[HttpPost("{id}/confirm")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult> Confirm(string id, [FromBody] ConfirmPendingConfigRequest? request)
{
try
{
var config = FindConfigById(id);
if (config == null)
{
return NotFound(new { message = $"Pending config {id} not found" });
}
// 解析配置 JSON
var configModel = JsonSerializer.Deserialize<GatewayConfigModel>(config.ConfigJson, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
if (configModel == null)
{
return BadRequest(new { message = "Invalid config format" });
}
// 获取当前用户
var confirmedBy = User.FindFirst(ClaimTypes.Name)?.Value ?? User.Identity?.Name ?? "system";
// 写入数据库
await SaveConfigToDatabaseAsync(configModel);
// 发送 PostgreSQL NOTIFY
await _notificationService.PublishConfigChangeAsync(
"route",
"create",
new { configId = id, serviceName = config.ServiceName, clusterId = config.ClusterId }
);
// 从缓存中移除
_pendingConfigCache.Remove(config.SourceId);
_logger.LogInformation(
"Pending config {ConfigId} confirmed by {ConfirmedBy}. Service: {ServiceName}",
id, confirmedBy, config.ServiceName);
return Ok(new { message = "Config confirmed successfully", configId = id });
}
catch (JsonException ex)
{
_logger.LogError(ex, "Error parsing config JSON for {ConfigId}", id);
return BadRequest(new { message = "Invalid config JSON format" });
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Validation error confirming config {ConfigId}", id);
return BadRequest(new { message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error confirming pending config {ConfigId}", id);
return StatusCode(500, new { message = ex.Message });
}
}
/// <summary>
/// 拒绝待确认配置
/// 从缓存中移除配置,并可选择记录拒绝原因
/// </summary>
/// <param name="id">配置ID</param>
/// <param name="request">拒绝请求,包含可选的拒绝原因</param>
/// <returns>操作结果</returns>
/// <response code="200">成功拒绝配置</response>
/// <response code="404">配置不存在</response>
/// <response code="500">服务器内部错误</response>
[HttpPost("{id}/reject")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public ActionResult Reject(string id, [FromBody] RejectPendingConfigRequest? request)
{
try
{
var config = FindConfigById(id);
if (config == null)
{
return NotFound(new { message = $"Pending config {id} not found" });
}
// 从缓存中移除
var removed = _pendingConfigCache.Remove(config.SourceId);
if (!removed)
{
return NotFound(new { message = $"Pending config {id} not found in cache" });
}
// 记录拒绝原因(可选)
var reason = request?.Reason ?? "No reason provided";
_logger.LogInformation(
"Pending config {ConfigId} rejected. Reason: {Reason}. Service: {ServiceName}",
id, reason, config.ServiceName);
return Ok(new { message = "Config rejected successfully", configId = id, reason });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error rejecting pending config {ConfigId}", id);
return StatusCode(500, new { message = ex.Message });
}
}
/// <summary>
/// 修改待确认配置
/// 允许用户调整配置内容,更新缓存中的配置,并标记为 Modified 状态
/// </summary>
/// <param name="id">配置ID</param>
/// <param name="request">修改请求,包含新的配置内容和修改原因</param>
/// <returns>更新后的配置详情</returns>
/// <response code="200">成功修改配置</response>
/// <response code="404">配置不存在</response>
/// <response code="400">配置格式无效</response>
/// <response code="500">服务器内部错误</response>
[HttpPut("{id}/modify")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
[ProducesResponseType(typeof(PendingConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public ActionResult<PendingConfigDto> Modify(string id, [FromBody] ModifyPendingConfigRequest request)
{
try
{
if (request?.Config == null)
{
return BadRequest(new { message = "Config content is required" });
}
var config = FindConfigById(id);
if (config == null)
{
return NotFound(new { message = $"Pending config {id} not found" });
}
// 将新的配置内容序列化为 JSON
var newConfigJson = request.Config.ToJsonString(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
// 更新配置
config.ConfigJson = newConfigJson;
config.Status = PendingConfigStatus.Modified;
config.IsModified = true;
// 尝试解析配置以更新相关字段
try
{
var configModel = JsonSerializer.Deserialize<GatewayConfigModel>(newConfigJson, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
if (configModel != null)
{
config.ServiceName = configModel.ServiceName;
config.TenantCode = configModel.TenantCode;
config.ClusterId = configModel.ClusterId;
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse modified config for {ConfigId}", id);
// 即使解析失败,仍然保存 JSON 内容
}
// 更新缓存
_pendingConfigCache.AddOrUpdate(config);
_logger.LogInformation(
"Pending config {ConfigId} modified. Reason: {Reason}. Service: {ServiceName}",
id, request.Reason ?? "No reason provided", config.ServiceName);
return Ok(MapToDto(config));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error modifying pending config {ConfigId}", id);
return StatusCode(500, new { message = ex.Message });
}
}
/// <summary>
/// 根据 ID 查找配置
/// </summary>
private PendingConfig? FindConfigById(string id)
{
return _pendingConfigCache.GetAll().FirstOrDefault(c => c.Id == id);
}
/// <summary>
/// 将 PendingConfig 映射为 PendingConfigDto
/// </summary>
private static PendingConfigDto MapToDto(PendingConfig config)
{
JsonNode? configNode = null;
try
{
if (!string.IsNullOrWhiteSpace(config.ConfigJson))
{
configNode = JsonNode.Parse(config.ConfigJson);
}
}
catch (JsonException)
{
// 忽略解析错误,返回 null
}
return new PendingConfigDto
{
Id = config.Id,
Type = config.Type.ToString(),
Source = config.Source.ToString(),
SourceId = config.SourceId,
Config = configNode,
ServiceName = config.ServiceName,
TenantCode = config.TenantCode,
ClusterId = config.ClusterId,
Status = config.Status.ToString(),
CreatedAt = config.CreatedAt,
ConfirmedAt = config.ConfirmedAt,
ConfirmedBy = config.ConfirmedBy,
IsNew = config.IsNew,
IsModified = config.IsModified
};
}
/// <summary>
/// 将配置保存到数据库
/// </summary>
private async Task SaveConfigToDatabaseAsync(GatewayConfigModel config)
{
// 1. 创建或更新 Cluster
var cluster = await _clusterStore.FindByClusterIdAsync(config.ClusterId);
if (cluster == null)
{
cluster = new GwCluster
{
Id = Guid.CreateVersion7().ToString("N"),
ClusterId = config.ClusterId,
Name = $"{config.ServiceName} Cluster",
Destinations = new List<GwDestination>()
};
await _clusterStore.CreateAsync(cluster);
_logger.LogInformation("Created new cluster: {ClusterId}", config.ClusterId);
}
// 2. 添加 Destination 到 Cluster
var serviceAddress = config.ServiceAddress ?? $"http://{config.ServiceName}";
var port = config.Port ?? 80;
var destinationAddress = $"{serviceAddress}:{port}";
var destination = new GwDestination
{
DestinationId = config.DestinationId,
Address = destinationAddress,
Weight = config.Weight,
HealthStatus = 1, // Healthy
Status = 1, // Active
TenantCode = config.TenantCode ?? ""
};
await _clusterStore.AddDestinationAsync(config.ClusterId, destination);
_logger.LogInformation(
"Added destination {DestinationId} to cluster {ClusterId}: {Address}",
config.DestinationId, config.ClusterId, destinationAddress);
// 3. 创建 Route
var pathPattern = string.IsNullOrEmpty(config.PathPrefix)
? $"/{config.ServiceName}/{{**path}}"
: config.PathPrefix.TrimEnd('/') + "/{**path}";
var routeId = Guid.CreateVersion7().ToString("N");
var route = new GwTenantRoute
{
Id = routeId,
TenantCode = config.IsGlobal ? "" : (config.TenantCode ?? ""),
ServiceName = config.ServiceName,
ClusterId = config.ClusterId,
Match = new GwRouteMatch
{
Path = pathPattern,
Hosts = !string.IsNullOrEmpty(config.Host) ? new List<string> { config.Host } : null
},
Priority = config.IsGlobal ? 0 : 10,
Status = (int)RouteStatus.Active,
IsGlobal = config.IsGlobal,
CreatedTime = DateTime.UtcNow
};
await _routeStore.CreateAsync(route);
_logger.LogInformation(
"Created new route: {RouteId} for service {ServiceName} with path {PathPattern}",
routeId, config.ServiceName, pathPattern);
}
}

View File

@ -0,0 +1,176 @@
using System.Text.Json.Nodes;
using Fengling.Console.Models.Entities;
namespace Fengling.Console.Models.Dtos;
/// <summary>
/// 待确认配置 DTO
/// 返回给前端的配置详情
/// </summary>
public class PendingConfigDto
{
/// <summary>
/// 唯一标识
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// 配置类型
/// </summary>
public string Type { get; set; } = "";
/// <summary>
/// 配置来源
/// </summary>
public string Source { get; set; } = "";
/// <summary>
/// 来源标识(如 K8s Service UID
/// </summary>
public string SourceId { get; set; } = "";
/// <summary>
/// 配置内容JSON 对象)
/// </summary>
public JsonNode? Config { 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 string Status { get; set; } = "";
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <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; }
}
/// <summary>
/// 待确认配置列表查询请求
/// </summary>
public class PendingConfigListRequest
{
/// <summary>
/// 页码,从 1 开始
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页大小,默认 10
/// </summary>
public int PageSize { get; set; } = 10;
/// <summary>
/// 按状态筛选Pending, Confirmed, Rejected, Modified
/// </summary>
public PendingConfigStatus? Status { get; set; }
/// <summary>
/// 按服务名称搜索(支持模糊匹配)
/// </summary>
public string? ServiceName { get; set; }
/// <summary>
/// 按租户编码筛选
/// </summary>
public string? TenantCode { get; set; }
/// <summary>
/// 按配置来源筛选K8sDiscovery, Manual
/// </summary>
public PendingConfigSource? Source { get; set; }
}
/// <summary>
/// 确认待确认配置请求
/// </summary>
public class ConfirmPendingConfigRequest
{
/// <summary>
/// 确认备注信息
/// </summary>
public string? Remark { get; set; }
}
/// <summary>
/// 拒绝待确认配置请求
/// </summary>
public class RejectPendingConfigRequest
{
/// <summary>
/// 拒绝原因
/// </summary>
public string? Reason { get; set; }
}
/// <summary>
/// 修改待确认配置请求
/// </summary>
public class ModifyPendingConfigRequest
{
/// <summary>
/// 修改后的配置内容JSON 对象)
/// </summary>
public JsonNode? Config { get; set; }
/// <summary>
/// 修改原因
/// </summary>
public string? Reason { get; set; }
}
/// <summary>
/// 待确认配置统计信息
/// </summary>
public class PendingConfigStatisticsDto
{
/// <summary>
/// 待确认总数
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 新配置数量
/// </summary>
public int NewCount { get; set; }
/// <summary>
/// 变更配置数量
/// </summary>
public int ModifiedCount { get; set; }
}

View File

@ -96,6 +96,9 @@ builder.Services.AddSwaggerGen(c =>
});
// 添加通知服务
builder.Services.AddNotificationService();
// 添加 K8s 服务监视
builder.Services.AddServiceLabelParser();
builder.Services.AddPendingConfigCache();