feat(config): 接入 Consul KV 配置中心 + IOptionsMonitor 热重载

配置中心:
- BuildFlow.Host.csproj 加 Winton.Extensions.Configuration.Consul 3.0.1
- Program.cs 注册 AddConsul(Optional=true 容错,ReloadOnChange=true 热重载)
- 配置优先级:appsettings.json → Secrets → Consul KV(最高,生产真相源)

热重载(IOptions<T> → IOptionsMonitor<T>):
- 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/* 前缀
This commit is contained in:
xiangning 2026-07-17 17:17:24 +08:00
parent 74e99850e5
commit f43e35a4b1
11 changed files with 109 additions and 61 deletions

View File

@ -54,6 +54,8 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.11.0-beta.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" />
<!-- Consul KV 配置中心:作为 IConfiguration Provider 接入,支持 ReloadOnChange 热重载 -->
<PackageReference Include="Winton.Extensions.Configuration.Consul" Version="3.0.1" />
</ItemGroup>
</Project>

View File

@ -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 =>

View File

@ -15,26 +15,28 @@ public class ImportJobProcessorService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ImportJobProcessorService> _logger;
private readonly NotificationOptions _options;
private readonly IOptionsMonitor<NotificationOptions> _options;
public ImportJobProcessorService(
IServiceScopeFactory scopeFactory,
ILogger<ImportJobProcessorService> logger,
IOptions<NotificationOptions> options)
IOptionsMonitor<NotificationOptions> 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 范围),

View File

@ -17,23 +17,23 @@ namespace BuildFlow.Host.Services.BackgroundServices;
/// </summary>
public class MonitoringSyncSchedulerService(
IServiceScopeFactory scopeFactory,
IOptions<MonitoringOptions> options,
IOptionsMonitor<MonitoringOptions> options,
ILogger<MonitoringSyncSchedulerService> 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<SyncProcessor>();
var now = DateTime.UtcNow;
var defaultInterval = _options.Scheduler.PollIntervalSeconds;
// 每轮扫描读最新配置(业务参数热重载,timer 间隔除外)
var defaultInterval = options.CurrentValue.Scheduler.PollIntervalSeconds;
// 启用/间隔/最近同步时间都在 json_data 内,先物化全部再在内存过滤
// (外部系统数量很少,全表扫描无压力;若未来量大再加 jsonb 索引或冗余列)

View File

@ -14,26 +14,28 @@ public class TimeoutSchedulerService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<TimeoutSchedulerService> _logger;
private readonly NotificationOptions _options;
private readonly IOptionsMonitor<NotificationOptions> _options;
public TimeoutSchedulerService(
IServiceScopeFactory scopeFactory,
ILogger<TimeoutSchedulerService> logger,
IOptions<NotificationOptions> options)
IOptionsMonitor<NotificationOptions> 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 崩溃进程。

View File

@ -21,34 +21,36 @@ public class WebhookDispatcherService : BackgroundService
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<WebhookDispatcherService> _logger;
private readonly NotificationOptions _options;
private readonly IOptionsMonitor<NotificationOptions> _options;
public WebhookDispatcherService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
IOptions<NotificationOptions> options,
IOptionsMonitor<NotificationOptions> options,
ILogger<WebhookDispatcherService> 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
/// <summary>SSRF 防护:校验 URL 主机在白名单内,且拒绝 localhost 与私有/内网 IP。</summary>
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));
}
}

View File

@ -15,15 +15,17 @@ namespace File.Application.Features.Files.Handlers;
/// internalToken 为后端签发的短时 HMAC 签名(非用户 JWT),仅用于 KKFileView 内部回拉,10 分钟有效。
/// </summary>
public class GetOfficePreviewQueryHandler(
IOptions<KkFileViewOptions> kkOptions) : IRequestHandler<GetOfficePreviewQuery, OfficePreviewResult>
IOptionsMonitor<KkFileViewOptions> kkOptions) : IRequestHandler<GetOfficePreviewQuery, OfficePreviewResult>
{
/// <summary>内部回拉 token 有效期:KKFileView 拉取文件的时间窗口。</summary>
private const int TokenValidityMinutes = 10;
public Task<OfficePreviewResult> 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));

View File

@ -34,12 +34,10 @@ public class FileInfrastructureModule : AbpModule
services.AddScoped<AuditInterceptor>();
// S3 兼容存储(单例,AmazonS3Client 线程安全可复用)
// 注入 IOptionsMonitor:Consul KV 改 S3 配置时 OnChange 回调重建 client,热重载生效
services.Configure<S3Options>(config.GetSection(S3Options.SectionName));
services.AddSingleton<IS3StorageService>(sp =>
{
var options = sp.GetRequiredService<IOptions<S3Options>>().Value;
return new S3StorageService(Options.Create(options));
});
new S3StorageService(sp.GetRequiredService<IOptionsMonitor<S3Options>>()));
// KKFileView 在线预览服务配置(Office/PDF 文档预览,生成预览地址时读取)
services.Configure<KkFileViewOptions>(config.GetSection(KkFileViewOptions.SectionName));

View File

@ -12,11 +12,30 @@ namespace File.Infrastructure.Storage;
/// </summary>
public class S3StorageService : IS3StorageService
{
private readonly IAmazonS3 _client;
// volatile:OnChange 回调会在配置变更时重建 client 并重新赋值该字段,
// volatile 保证其他线程立即看到新引用(避免读到半构造对象)。
private volatile IAmazonS3 _client;
private readonly Lock _clientLock = new();
public S3StorageService(IOptions<S3Options> options)
public S3StorageService(IOptionsMonitor<S3Options> 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);
}
/// <summary>把 S3 的 NotFound 类错误包装为领域 NotFoundException,其他原样抛出。</summary>

View File

@ -8,18 +8,19 @@ namespace BuildFlow.Infrastructure.AI;
/// <summary>
/// AI 运行时模型配置解析器。优先读取模型管理中的默认启用模型,没有配置时回退到 appsettings。
/// 配置通过 IOptionsMonitor 实时读取,Consul KV 改动即时生效(无需重启)。
/// </summary>
public class AiModelRuntimeConfigResolver(
IServiceScopeFactory scopeFactory,
IOptions<AiOptions> options)
IOptionsMonitor<AiOptions> options)
{
private readonly AiOptions _fallbackOptions = options.Value;
/// <summary>
/// 按模型类型解析本次调用配置。Chat 与 Embedding 都通过该入口,避免某一类调用绕过本地模型配置。
/// </summary>
public async Task<AiModelRuntimeConfig> ResolveAsync(ModelType modelType, CancellationToken ct)
{
// 每次解析读最新配置(IOptionsMonitor.CurrentValue),支持热重载
var fallback = options.CurrentValue;
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<RagDbContext>();
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;
}

View File

@ -17,10 +17,10 @@ namespace Workflow.Application.Notifications;
/// </summary>
public class NotificationService(
WorkflowDbContext db,
IOptions<NotificationOptions> options,
IOptionsMonitor<NotificationOptions> 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(
/// </summary>
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));
}
}