From f43e35a4b1dfe10ace8af5a275eb1556bc2c413c Mon Sep 17 00:00:00 2001 From: xiangning Date: Fri, 17 Jul 2026 17:17:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=8E=A5=E5=85=A5=20Consul=20K?= =?UTF-8?q?V=20=E9=85=8D=E7=BD=AE=E4=B8=AD=E5=BF=83=20+=20IOptionsMonitor?= =?UTF-8?q?=20=E7=83=AD=E9=87=8D=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置中心: - BuildFlow.Host.csproj 加 Winton.Extensions.Configuration.Consul 3.0.1 - Program.cs 注册 AddConsul(Optional=true 容错,ReloadOnChange=true 热重载) - 配置优先级:appsettings.json → Secrets → Consul KV(最高,生产真相源) 热重载(IOptions → IOptionsMonitor): - KkFileView/AiOptions/NotificationService:.Value → .CurrentValue,完全热重载 - 4 个 HostedService:业务参数热重载,timer 间隔启动时定一次(方案 A) - S3StorageService:IOptionsMonitor + OnChange 回调重建 AmazonS3Client(volatile + lock) - 测试全过(Rag 40 + Workflow Notification 21) compose 加 Consul__Host 环境变量(api 容器注入 Consul 地址) 后续:Consul 部署在 middleware,KV 全量灌入 buildflow/* 前缀 --- src/BuildFlow.Host/BuildFlow.Host.csproj | 2 ++ src/BuildFlow.Host/Program.cs | 16 ++++++++++ .../ImportJobProcessorService.cs | 14 +++++---- .../MonitoringSyncSchedulerService.cs | 15 +++++----- .../TimeoutSchedulerService.cs | 14 +++++---- .../WebhookDispatcherService.cs | 24 ++++++++------- .../Handlers/GetOfficePreviewQueryHandler.cs | 10 ++++--- .../FileInfrastructureModule.cs | 6 ++-- .../Storage/S3StorageService.cs | 27 ++++++++++++++--- .../AI/AiModelRuntimeConfigResolver.cs | 29 ++++++++++--------- .../Notifications/NotificationService.cs | 13 +++++---- 11 files changed, 109 insertions(+), 61 deletions(-) diff --git a/src/BuildFlow.Host/BuildFlow.Host.csproj b/src/BuildFlow.Host/BuildFlow.Host.csproj index 3679945..23bcf6d 100644 --- a/src/BuildFlow.Host/BuildFlow.Host.csproj +++ b/src/BuildFlow.Host/BuildFlow.Host.csproj @@ -54,6 +54,8 @@ + + diff --git a/src/BuildFlow.Host/Program.cs b/src/BuildFlow.Host/Program.cs index 82d055e..0834533 100644 --- a/src/BuildFlow.Host/Program.cs +++ b/src/BuildFlow.Host/Program.cs @@ -1,6 +1,7 @@ using BuildFlow.Host; using BuildFlow.Host.Configuration; using OpenTelemetry.Logs; +using Winton.Extensions.Configuration.Consul; var builder = WebApplication.CreateBuilder(args); @@ -8,6 +9,21 @@ var builder = WebApplication.CreateBuilder(args); // 不存在时 optional:true 静默跳过,回退到 appsettings.json 中的占位符。 builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true); +// Consul KV 配置中心(优先级最高,覆盖本地 json + secrets,作为生产配置真相源)。 +// 加载顺序决定优先级:后注册的覆盖先注册的,故 Consul 在 Secrets 之后。 +// 鸡生蛋问题:Consul 地址本身用环境变量 Consul__Host 注入(compose 配置), +// 默认回退到 middleware 公网地址。Consul 不可用时 Optional=true 回退本地 json,不阻断启动。 +var consulHost = builder.Configuration["Consul:Host"] ?? "http://43.134.235.58:8500"; +builder.Configuration.AddConsul( + "buildflow/", + options => + { + options.ConsulConfigurationOptions = co => co.Address = new Uri(consulHost); + options.Optional = true; // Consul 不可用不阻断启动,回退本地 appsettings + options.ReloadOnChange = true; // KV 变更自动刷新 IConfiguration(热重载基础) + options.OnLoadException = ex => ex.Ignore = true; // 容错:Consul 抖动不影响应用 + }); + // OTel 日志导出 var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316"; builder.Logging.AddOpenTelemetry(logging => diff --git a/src/BuildFlow.Host/Services/BackgroundServices/ImportJobProcessorService.cs b/src/BuildFlow.Host/Services/BackgroundServices/ImportJobProcessorService.cs index ae3029d..432f238 100644 --- a/src/BuildFlow.Host/Services/BackgroundServices/ImportJobProcessorService.cs +++ b/src/BuildFlow.Host/Services/BackgroundServices/ImportJobProcessorService.cs @@ -15,26 +15,28 @@ public class ImportJobProcessorService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; - private readonly NotificationOptions _options; + private readonly IOptionsMonitor _options; public ImportJobProcessorService( IServiceScopeFactory scopeFactory, ILogger logger, - IOptions options) + IOptionsMonitor options) { _scopeFactory = scopeFactory; _logger = logger; - _options = options.Value; + _options = options; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("ImportJobProcessor 启动,轮询间隔 {Interval}s", _options.Scheduler.PollIntervalSeconds); + // timer 间隔启动时定一次(方案 A:timer 参数不热重载,改间隔需重启服务) + var opts = _options.CurrentValue; + _logger.LogInformation("ImportJobProcessor 启动,轮询间隔 {Interval}s", opts.Scheduler.PollIntervalSeconds); // 启动时先等待一轮,避免与应用启动并发争抢资源 - await Task.Delay(_options.Scheduler.StartupDelaySeconds * 1000, stoppingToken); + await Task.Delay(opts.Scheduler.StartupDelaySeconds * 1000, stoppingToken); - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.Scheduler.PollIntervalSeconds)); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(opts.Scheduler.PollIntervalSeconds)); // 整个 do-while 纳入 try/catch:timer.WaitForNextTickAsync 在 stoppingToken 取消时 // 会抛 OperationCanceledException,该异常发生在 while 条件求值处(不在内层 try 范围), diff --git a/src/BuildFlow.Host/Services/BackgroundServices/MonitoringSyncSchedulerService.cs b/src/BuildFlow.Host/Services/BackgroundServices/MonitoringSyncSchedulerService.cs index 7c832df..62e1913 100644 --- a/src/BuildFlow.Host/Services/BackgroundServices/MonitoringSyncSchedulerService.cs +++ b/src/BuildFlow.Host/Services/BackgroundServices/MonitoringSyncSchedulerService.cs @@ -17,23 +17,23 @@ namespace BuildFlow.Host.Services.BackgroundServices; /// public class MonitoringSyncSchedulerService( IServiceScopeFactory scopeFactory, - IOptions options, + IOptionsMonitor options, ILogger logger) : BackgroundService { - private readonly MonitoringOptions _options = options.Value; - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // timer 间隔启动时定一次(方案 A:timer 参数不热重载,改间隔需重启服务) + var opts = options.CurrentValue; // 整个方法体纳入 try/catch:Task.Delay / WaitForNextTickAsync 在进程关闭时 // 抛 OperationCanceledException,不捕获会触发 StopHost 崩溃整个进程。 try { - await Task.Delay(_options.Scheduler.StartupDelaySeconds * 1000, stoppingToken); + await Task.Delay(opts.Scheduler.StartupDelaySeconds * 1000, stoppingToken); logger.LogInformation("MonitoringSyncScheduler 启动,轮询间隔 {Interval}s", - _options.Scheduler.PollIntervalSeconds); + opts.Scheduler.PollIntervalSeconds); - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.Scheduler.PollIntervalSeconds)); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(opts.Scheduler.PollIntervalSeconds)); do { @@ -67,7 +67,8 @@ public class MonitoringSyncSchedulerService( var processor = scope.ServiceProvider.GetRequiredService(); var now = DateTime.UtcNow; - var defaultInterval = _options.Scheduler.PollIntervalSeconds; + // 每轮扫描读最新配置(业务参数热重载,timer 间隔除外) + var defaultInterval = options.CurrentValue.Scheduler.PollIntervalSeconds; // 启用/间隔/最近同步时间都在 json_data 内,先物化全部再在内存过滤 // (外部系统数量很少,全表扫描无压力;若未来量大再加 jsonb 索引或冗余列) diff --git a/src/BuildFlow.Host/Services/BackgroundServices/TimeoutSchedulerService.cs b/src/BuildFlow.Host/Services/BackgroundServices/TimeoutSchedulerService.cs index 85c91e2..4f53420 100644 --- a/src/BuildFlow.Host/Services/BackgroundServices/TimeoutSchedulerService.cs +++ b/src/BuildFlow.Host/Services/BackgroundServices/TimeoutSchedulerService.cs @@ -14,26 +14,28 @@ public class TimeoutSchedulerService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; - private readonly NotificationOptions _options; + private readonly IOptionsMonitor _options; public TimeoutSchedulerService( IServiceScopeFactory scopeFactory, ILogger logger, - IOptions options) + IOptionsMonitor options) { _scopeFactory = scopeFactory; _logger = logger; - _options = options.Value; + _options = options; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("TimeoutScheduler 启动,轮询间隔 {Interval}s", _options.Scheduler.PollIntervalSeconds); + // timer 间隔启动时定一次(方案 A:timer 参数不热重载,改间隔需重启服务) + var opts = _options.CurrentValue; + _logger.LogInformation("TimeoutScheduler 启动,轮询间隔 {Interval}s", opts.Scheduler.PollIntervalSeconds); // 启动时先等待一轮,避免与应用启动并发争抢资源 - await Task.Delay(_options.Scheduler.StartupDelaySeconds * 1000, stoppingToken); + await Task.Delay(opts.Scheduler.StartupDelaySeconds * 1000, stoppingToken); - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.Scheduler.PollIntervalSeconds)); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(opts.Scheduler.PollIntervalSeconds)); // 整个 do-while 纳入 try/catch:WaitForNextTickAsync 取消时抛的 OperationCanceledException // 发生在 while 条件处(不在内层 try),不捕获会触发 StopHost 崩溃进程。 diff --git a/src/BuildFlow.Host/Services/BackgroundServices/WebhookDispatcherService.cs b/src/BuildFlow.Host/Services/BackgroundServices/WebhookDispatcherService.cs index c09a8c8..dee99b7 100644 --- a/src/BuildFlow.Host/Services/BackgroundServices/WebhookDispatcherService.cs +++ b/src/BuildFlow.Host/Services/BackgroundServices/WebhookDispatcherService.cs @@ -21,34 +21,36 @@ public class WebhookDispatcherService : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - private readonly NotificationOptions _options; + private readonly IOptionsMonitor _options; public WebhookDispatcherService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, - IOptions options, + IOptionsMonitor options, ILogger logger) { _scopeFactory = scopeFactory; _httpClientFactory = httpClientFactory; _logger = logger; - _options = options.Value; + _options = options; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // 启动时读一次:AllowedHosts 决定是否启用、timer 间隔(方案 A:不热重载) + var opts = _options.CurrentValue; // 未配置任何 AllowedHosts 时,Webhook 投递被完全禁用,服务空转退出(避免无谓轮询) - if (_options.Webhook.AllowedHosts.Length == 0) + if (opts.Webhook.AllowedHosts.Length == 0) { _logger.LogInformation("WebhookDispatcher:AllowedHosts 为空,Webhook 投递已禁用,服务退出"); return; } - _logger.LogInformation("WebhookDispatcher 启动,轮询间隔 {Interval}s", _options.Webhook.PollIntervalSeconds); + _logger.LogInformation("WebhookDispatcher 启动,轮询间隔 {Interval}s", opts.Webhook.PollIntervalSeconds); - await Task.Delay(_options.Scheduler.StartupDelaySeconds * 1000, stoppingToken); + await Task.Delay(opts.Scheduler.StartupDelaySeconds * 1000, stoppingToken); - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.Webhook.PollIntervalSeconds)); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(opts.Webhook.PollIntervalSeconds)); do { @@ -89,7 +91,7 @@ public class WebhookDispatcherService : BackgroundService return; var client = _httpClientFactory.CreateClient("Webhook"); - client.Timeout = TimeSpan.FromSeconds(_options.Webhook.TimeoutSeconds); + client.Timeout = TimeSpan.FromSeconds(_options.CurrentValue.Webhook.TimeoutSeconds); foreach (var delivery in pending) { @@ -186,7 +188,9 @@ public class WebhookDispatcherService : BackgroundService /// SSRF 防护:校验 URL 主机在白名单内,且拒绝 localhost 与私有/内网 IP。 internal bool IsHostAllowed(string url) { - if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0) + // 每次校验读最新配置(AllowedHosts 热重载,Consul KV 改白名单即时生效) + var allowedHosts = _options.CurrentValue.Webhook.AllowedHosts; + if (allowedHosts is null || allowedHosts.Length == 0) return false; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) @@ -213,7 +217,7 @@ public class WebhookDispatcherService : BackgroundService } } - return _options.Webhook.AllowedHosts + return allowedHosts .Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase)); } } \ No newline at end of file diff --git a/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs b/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs index 314df88..980a1ed 100644 --- a/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/GetOfficePreviewQueryHandler.cs @@ -15,15 +15,17 @@ namespace File.Application.Features.Files.Handlers; /// internalToken 为后端签发的短时 HMAC 签名(非用户 JWT),仅用于 KKFileView 内部回拉,10 分钟有效。 /// public class GetOfficePreviewQueryHandler( - IOptions kkOptions) : IRequestHandler + IOptionsMonitor kkOptions) : IRequestHandler { /// 内部回拉 token 有效期:KKFileView 拉取文件的时间窗口。 private const int TokenValidityMinutes = 10; public Task Handle(GetOfficePreviewQuery req, CancellationToken ct) { - var backendBase = kkOptions.Value.BackendBaseUrl.TrimEnd('/'); - var internalKey = kkOptions.Value.InternalKey; + // IOptionsMonitor.CurrentValue:每次请求读最新配置,Consul KV 改动实时生效 + var opts = kkOptions.CurrentValue; + var backendBase = opts.BackendBaseUrl.TrimEnd('/'); + var internalKey = opts.InternalKey; // 生成短时 HMAC 签名:objectKey + 过期时间戳 var expires = DateTimeOffset.UtcNow.AddMinutes(TokenValidityMinutes).ToUnixTimeSeconds(); @@ -37,7 +39,7 @@ public class GetOfficePreviewQueryHandler( // KKFileView onlinePreview 约定:url 参数为 Base64 编码的文件直链 var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(proxyUrl)); - var kkBase = kkOptions.Value.BaseUrl.TrimEnd('/'); + var kkBase = opts.BaseUrl.TrimEnd('/'); var previewUrl = $"{kkBase}/onlinePreview?url={encoded}"; return Task.FromResult(new OfficePreviewResult(previewUrl)); diff --git a/src/modules/file/Infrastructure/FileInfrastructureModule.cs b/src/modules/file/Infrastructure/FileInfrastructureModule.cs index aa4b35a..609a0da 100644 --- a/src/modules/file/Infrastructure/FileInfrastructureModule.cs +++ b/src/modules/file/Infrastructure/FileInfrastructureModule.cs @@ -34,12 +34,10 @@ public class FileInfrastructureModule : AbpModule services.AddScoped(); // S3 兼容存储(单例,AmazonS3Client 线程安全可复用) + // 注入 IOptionsMonitor:Consul KV 改 S3 配置时 OnChange 回调重建 client,热重载生效 services.Configure(config.GetSection(S3Options.SectionName)); services.AddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - return new S3StorageService(Options.Create(options)); - }); + new S3StorageService(sp.GetRequiredService>())); // KKFileView 在线预览服务配置(Office/PDF 文档预览,生成预览地址时读取) services.Configure(config.GetSection(KkFileViewOptions.SectionName)); diff --git a/src/modules/file/Infrastructure/Storage/S3StorageService.cs b/src/modules/file/Infrastructure/Storage/S3StorageService.cs index 39cd276..32f0d09 100644 --- a/src/modules/file/Infrastructure/Storage/S3StorageService.cs +++ b/src/modules/file/Infrastructure/Storage/S3StorageService.cs @@ -12,11 +12,30 @@ namespace File.Infrastructure.Storage; /// public class S3StorageService : IS3StorageService { - private readonly IAmazonS3 _client; + // volatile:OnChange 回调会在配置变更时重建 client 并重新赋值该字段, + // volatile 保证其他线程立即看到新引用(避免读到半构造对象)。 + private volatile IAmazonS3 _client; + private readonly Lock _clientLock = new(); - public S3StorageService(IOptions options) + public S3StorageService(IOptionsMonitor options) + { + _client = BuildClient(options.CurrentValue); + // 配置变更(如 Consul KV 改 Endpoint/凭据)时重建 client,热重载生效。 + // 重建期间并发的旧请求仍用旧 client(volatile 读),重建完成后新请求用新 client。 + options.OnChange(opt => + { + var newClient = BuildClient(opt); + lock (_clientLock) + { + var old = _client; + _client = newClient; + old?.Dispose(); + } + }); + } + + private static AmazonS3Client BuildClient(S3Options opt) { - var opt = options.Value; var region = string.IsNullOrEmpty(opt.Region) ? "us-east-1" : opt.Region; var config = new AmazonS3Config { @@ -25,7 +44,7 @@ public class S3StorageService : IS3StorageService // ForcePathStyle:RustFS/MinIO 等 S3 兼容存储要求 path-style 寻址(bucket 放在路径而非子域)。 ForcePathStyle = true, }; - _client = new AmazonS3Client(opt.AccessKey, opt.SecretKey, config); + return new AmazonS3Client(opt.AccessKey, opt.SecretKey, config); } /// 把 S3 的 NotFound 类错误包装为领域 NotFoundException,其他原样抛出。 diff --git a/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfigResolver.cs b/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfigResolver.cs index 1f59ada..18c6840 100644 --- a/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfigResolver.cs +++ b/src/modules/rag/Infrastructure/AI/AiModelRuntimeConfigResolver.cs @@ -8,18 +8,19 @@ namespace BuildFlow.Infrastructure.AI; /// /// AI 运行时模型配置解析器。优先读取模型管理中的默认启用模型,没有配置时回退到 appsettings。 +/// 配置通过 IOptionsMonitor 实时读取,Consul KV 改动即时生效(无需重启)。 /// public class AiModelRuntimeConfigResolver( IServiceScopeFactory scopeFactory, - IOptions options) + IOptionsMonitor options) { - private readonly AiOptions _fallbackOptions = options.Value; - /// /// 按模型类型解析本次调用配置。Chat 与 Embedding 都通过该入口,避免某一类调用绕过本地模型配置。 /// public async Task ResolveAsync(ModelType modelType, CancellationToken ct) { + // 每次解析读最新配置(IOptionsMonitor.CurrentValue),支持热重载 + var fallback = options.CurrentValue; using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var model = await db.AiModels @@ -32,31 +33,31 @@ public class AiModelRuntimeConfigResolver( model.ModelId, model.BaseUrl, model.ApiKey, - ResolveDefaultInstructions(model.DefaultInstructions), + ResolveDefaultInstructions(model.DefaultInstructions, fallback), model.MaxTokens, model.Temperature); } var fallbackModelId = modelType == ModelType.Embedding - ? _fallbackOptions.EmbeddingModel - : _fallbackOptions.ChatModel; + ? fallback.EmbeddingModel + : fallback.ChatModel; return new AiModelRuntimeConfig( fallbackModelId, - _fallbackOptions.BaseUrl, - _fallbackOptions.ApiKey, - _fallbackOptions.DefaultInstructions, - _fallbackOptions.MaxTokens, - _fallbackOptions.Temperature); + fallback.BaseUrl, + fallback.ApiKey, + fallback.DefaultInstructions, + fallback.MaxTokens, + fallback.Temperature); } - private string? ResolveDefaultInstructions(string? modelInstructions) + private static string? ResolveDefaultInstructions(string? modelInstructions, AiOptions fallback) { if (string.IsNullOrWhiteSpace(modelInstructions)) - return _fallbackOptions.DefaultInstructions; + return fallback.DefaultInstructions; return IsLegacyRagOnlyInstructions(modelInstructions) - ? _fallbackOptions.DefaultInstructions + ? fallback.DefaultInstructions : modelInstructions; } diff --git a/src/modules/workflow/Application/Notifications/NotificationService.cs b/src/modules/workflow/Application/Notifications/NotificationService.cs index 4b77522..1478b7c 100644 --- a/src/modules/workflow/Application/Notifications/NotificationService.cs +++ b/src/modules/workflow/Application/Notifications/NotificationService.cs @@ -17,10 +17,10 @@ namespace Workflow.Application.Notifications; /// public class NotificationService( WorkflowDbContext db, - IOptions options, + IOptionsMonitor options, IPlatformNotificationPublisher platformPublisher) : INotificationService { - private readonly NotificationOptions _options = options.Value; + // 配置通过 IOptionsMonitor 实时读取(Scoped,每请求重新 resolve),Consul KV 改动即时生效 public Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default) => CreateAndDispatchAsync(task, "task-arrived", @@ -179,7 +179,7 @@ public class NotificationService( HttpMethod = "POST", Status = "pending", Attempts = 0, - MaxAttempts = _options.Webhook.MaxAttempts, + MaxAttempts = options.CurrentValue.Webhook.MaxAttempts, NextRetryAt = now, Category = category, RelatedInstanceId = task.InstanceId, @@ -197,7 +197,7 @@ public class NotificationService( private string ResolveWebhookUrl() { // 当前实现使用全局 DefaultUrl;节点级 webhookUrl 配置可后续扩展(需 ProcessEngine 传入 node.Config) - var url = _options.Webhook.DefaultUrl; + var url = options.CurrentValue.Webhook.DefaultUrl; return string.IsNullOrWhiteSpace(url) ? string.Empty : url; } @@ -207,14 +207,15 @@ public class NotificationService( /// internal bool IsHostAllowed(string url) { - if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0) + var webhook = options.CurrentValue.Webhook; + if (webhook.AllowedHosts is null || webhook.AllowedHosts.Length == 0) return false; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; var host = uri.Host; - return _options.Webhook.AllowedHosts + return webhook.AllowedHosts .Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase)); } }