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.Http" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.11.0-beta.2" /> <PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.11.0-beta.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" /> <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> </ItemGroup>
</Project> </Project>

View File

@ -1,6 +1,7 @@
using BuildFlow.Host; using BuildFlow.Host;
using BuildFlow.Host.Configuration; using BuildFlow.Host.Configuration;
using OpenTelemetry.Logs; using OpenTelemetry.Logs;
using Winton.Extensions.Configuration.Consul;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -8,6 +9,21 @@ var builder = WebApplication.CreateBuilder(args);
// 不存在时 optional:true 静默跳过,回退到 appsettings.json 中的占位符。 // 不存在时 optional:true 静默跳过,回退到 appsettings.json 中的占位符。
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true); 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 日志导出 // OTel 日志导出
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316"; var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";
builder.Logging.AddOpenTelemetry(logging => builder.Logging.AddOpenTelemetry(logging =>

View File

@ -15,26 +15,28 @@ public class ImportJobProcessorService : BackgroundService
{ {
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ImportJobProcessorService> _logger; private readonly ILogger<ImportJobProcessorService> _logger;
private readonly NotificationOptions _options; private readonly IOptionsMonitor<NotificationOptions> _options;
public ImportJobProcessorService( public ImportJobProcessorService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<ImportJobProcessorService> logger, ILogger<ImportJobProcessorService> logger,
IOptions<NotificationOptions> options) IOptionsMonitor<NotificationOptions> options)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_logger = logger; _logger = logger;
_options = options.Value; _options = options;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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 取消时 // 整个 do-while 纳入 try/catch:timer.WaitForNextTickAsync 在 stoppingToken 取消时
// 会抛 OperationCanceledException,该异常发生在 while 条件求值处(不在内层 try 范围), // 会抛 OperationCanceledException,该异常发生在 while 条件求值处(不在内层 try 范围),

View File

@ -17,23 +17,23 @@ namespace BuildFlow.Host.Services.BackgroundServices;
/// </summary> /// </summary>
public class MonitoringSyncSchedulerService( public class MonitoringSyncSchedulerService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptions<MonitoringOptions> options, IOptionsMonitor<MonitoringOptions> options,
ILogger<MonitoringSyncSchedulerService> logger) : BackgroundService ILogger<MonitoringSyncSchedulerService> logger) : BackgroundService
{ {
private readonly MonitoringOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
// timer 间隔启动时定一次(方案 A:timer 参数不热重载,改间隔需重启服务)
var opts = options.CurrentValue;
// 整个方法体纳入 try/catch:Task.Delay / WaitForNextTickAsync 在进程关闭时 // 整个方法体纳入 try/catch:Task.Delay / WaitForNextTickAsync 在进程关闭时
// 抛 OperationCanceledException,不捕获会触发 StopHost 崩溃整个进程。 // 抛 OperationCanceledException,不捕获会触发 StopHost 崩溃整个进程。
try try
{ {
await Task.Delay(_options.Scheduler.StartupDelaySeconds * 1000, stoppingToken); await Task.Delay(opts.Scheduler.StartupDelaySeconds * 1000, stoppingToken);
logger.LogInformation("MonitoringSyncScheduler 启动,轮询间隔 {Interval}s", 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 do
{ {
@ -67,7 +67,8 @@ public class MonitoringSyncSchedulerService(
var processor = scope.ServiceProvider.GetRequiredService<SyncProcessor>(); var processor = scope.ServiceProvider.GetRequiredService<SyncProcessor>();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var defaultInterval = _options.Scheduler.PollIntervalSeconds; // 每轮扫描读最新配置(业务参数热重载,timer 间隔除外)
var defaultInterval = options.CurrentValue.Scheduler.PollIntervalSeconds;
// 启用/间隔/最近同步时间都在 json_data 内,先物化全部再在内存过滤 // 启用/间隔/最近同步时间都在 json_data 内,先物化全部再在内存过滤
// (外部系统数量很少,全表扫描无压力;若未来量大再加 jsonb 索引或冗余列) // (外部系统数量很少,全表扫描无压力;若未来量大再加 jsonb 索引或冗余列)

View File

@ -14,26 +14,28 @@ public class TimeoutSchedulerService : BackgroundService
{ {
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<TimeoutSchedulerService> _logger; private readonly ILogger<TimeoutSchedulerService> _logger;
private readonly NotificationOptions _options; private readonly IOptionsMonitor<NotificationOptions> _options;
public TimeoutSchedulerService( public TimeoutSchedulerService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<TimeoutSchedulerService> logger, ILogger<TimeoutSchedulerService> logger,
IOptions<NotificationOptions> options) IOptionsMonitor<NotificationOptions> options)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_logger = logger; _logger = logger;
_options = options.Value; _options = options;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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 // 整个 do-while 纳入 try/catch:WaitForNextTickAsync 取消时抛的 OperationCanceledException
// 发生在 while 条件处(不在内层 try),不捕获会触发 StopHost 崩溃进程。 // 发生在 while 条件处(不在内层 try),不捕获会触发 StopHost 崩溃进程。

View File

@ -21,34 +21,36 @@ public class WebhookDispatcherService : BackgroundService
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<WebhookDispatcherService> _logger; private readonly ILogger<WebhookDispatcherService> _logger;
private readonly NotificationOptions _options; private readonly IOptionsMonitor<NotificationOptions> _options;
public WebhookDispatcherService( public WebhookDispatcherService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<NotificationOptions> options, IOptionsMonitor<NotificationOptions> options,
ILogger<WebhookDispatcherService> logger) ILogger<WebhookDispatcherService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
_options = options.Value; _options = options;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
// 启动时读一次:AllowedHosts 决定是否启用、timer 间隔(方案 A:不热重载)
var opts = _options.CurrentValue;
// 未配置任何 AllowedHosts 时,Webhook 投递被完全禁用,服务空转退出(避免无谓轮询) // 未配置任何 AllowedHosts 时,Webhook 投递被完全禁用,服务空转退出(避免无谓轮询)
if (_options.Webhook.AllowedHosts.Length == 0) if (opts.Webhook.AllowedHosts.Length == 0)
{ {
_logger.LogInformation("WebhookDispatcher:AllowedHosts 为空,Webhook 投递已禁用,服务退出"); _logger.LogInformation("WebhookDispatcher:AllowedHosts 为空,Webhook 投递已禁用,服务退出");
return; 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 do
{ {
@ -89,7 +91,7 @@ public class WebhookDispatcherService : BackgroundService
return; return;
var client = _httpClientFactory.CreateClient("Webhook"); 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) foreach (var delivery in pending)
{ {
@ -186,7 +188,9 @@ public class WebhookDispatcherService : BackgroundService
/// <summary>SSRF 防护:校验 URL 主机在白名单内,且拒绝 localhost 与私有/内网 IP。</summary> /// <summary>SSRF 防护:校验 URL 主机在白名单内,且拒绝 localhost 与私有/内网 IP。</summary>
internal bool IsHostAllowed(string url) 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; return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) 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)); .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 分钟有效。 /// internalToken 为后端签发的短时 HMAC 签名(非用户 JWT),仅用于 KKFileView 内部回拉,10 分钟有效。
/// </summary> /// </summary>
public class GetOfficePreviewQueryHandler( public class GetOfficePreviewQueryHandler(
IOptions<KkFileViewOptions> kkOptions) : IRequestHandler<GetOfficePreviewQuery, OfficePreviewResult> IOptionsMonitor<KkFileViewOptions> kkOptions) : IRequestHandler<GetOfficePreviewQuery, OfficePreviewResult>
{ {
/// <summary>内部回拉 token 有效期:KKFileView 拉取文件的时间窗口。</summary> /// <summary>内部回拉 token 有效期:KKFileView 拉取文件的时间窗口。</summary>
private const int TokenValidityMinutes = 10; private const int TokenValidityMinutes = 10;
public Task<OfficePreviewResult> Handle(GetOfficePreviewQuery req, CancellationToken ct) public Task<OfficePreviewResult> Handle(GetOfficePreviewQuery req, CancellationToken ct)
{ {
var backendBase = kkOptions.Value.BackendBaseUrl.TrimEnd('/'); // IOptionsMonitor.CurrentValue:每次请求读最新配置,Consul KV 改动实时生效
var internalKey = kkOptions.Value.InternalKey; var opts = kkOptions.CurrentValue;
var backendBase = opts.BackendBaseUrl.TrimEnd('/');
var internalKey = opts.InternalKey;
// 生成短时 HMAC 签名:objectKey + 过期时间戳 // 生成短时 HMAC 签名:objectKey + 过期时间戳
var expires = DateTimeOffset.UtcNow.AddMinutes(TokenValidityMinutes).ToUnixTimeSeconds(); var expires = DateTimeOffset.UtcNow.AddMinutes(TokenValidityMinutes).ToUnixTimeSeconds();
@ -37,7 +39,7 @@ public class GetOfficePreviewQueryHandler(
// KKFileView onlinePreview 约定:url 参数为 Base64 编码的文件直链 // KKFileView onlinePreview 约定:url 参数为 Base64 编码的文件直链
var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(proxyUrl)); 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}"; var previewUrl = $"{kkBase}/onlinePreview?url={encoded}";
return Task.FromResult(new OfficePreviewResult(previewUrl)); return Task.FromResult(new OfficePreviewResult(previewUrl));

View File

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

View File

@ -12,11 +12,30 @@ namespace File.Infrastructure.Storage;
/// </summary> /// </summary>
public class S3StorageService : IS3StorageService 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 region = string.IsNullOrEmpty(opt.Region) ? "us-east-1" : opt.Region;
var config = new AmazonS3Config var config = new AmazonS3Config
{ {
@ -25,7 +44,7 @@ public class S3StorageService : IS3StorageService
// ForcePathStyle:RustFS/MinIO 等 S3 兼容存储要求 path-style 寻址(bucket 放在路径而非子域)。 // ForcePathStyle:RustFS/MinIO 等 S3 兼容存储要求 path-style 寻址(bucket 放在路径而非子域)。
ForcePathStyle = true, ForcePathStyle = true,
}; };
_client = new AmazonS3Client(opt.AccessKey, opt.SecretKey, config); return new AmazonS3Client(opt.AccessKey, opt.SecretKey, config);
} }
/// <summary>把 S3 的 NotFound 类错误包装为领域 NotFoundException,其他原样抛出。</summary> /// <summary>把 S3 的 NotFound 类错误包装为领域 NotFoundException,其他原样抛出。</summary>

View File

@ -8,18 +8,19 @@ namespace BuildFlow.Infrastructure.AI;
/// <summary> /// <summary>
/// AI 运行时模型配置解析器。优先读取模型管理中的默认启用模型,没有配置时回退到 appsettings。 /// AI 运行时模型配置解析器。优先读取模型管理中的默认启用模型,没有配置时回退到 appsettings。
/// 配置通过 IOptionsMonitor 实时读取,Consul KV 改动即时生效(无需重启)。
/// </summary> /// </summary>
public class AiModelRuntimeConfigResolver( public class AiModelRuntimeConfigResolver(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptions<AiOptions> options) IOptionsMonitor<AiOptions> options)
{ {
private readonly AiOptions _fallbackOptions = options.Value;
/// <summary> /// <summary>
/// 按模型类型解析本次调用配置。Chat 与 Embedding 都通过该入口,避免某一类调用绕过本地模型配置。 /// 按模型类型解析本次调用配置。Chat 与 Embedding 都通过该入口,避免某一类调用绕过本地模型配置。
/// </summary> /// </summary>
public async Task<AiModelRuntimeConfig> ResolveAsync(ModelType modelType, CancellationToken ct) public async Task<AiModelRuntimeConfig> ResolveAsync(ModelType modelType, CancellationToken ct)
{ {
// 每次解析读最新配置(IOptionsMonitor.CurrentValue),支持热重载
var fallback = options.CurrentValue;
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<RagDbContext>(); var db = scope.ServiceProvider.GetRequiredService<RagDbContext>();
var model = await db.AiModels var model = await db.AiModels
@ -32,31 +33,31 @@ public class AiModelRuntimeConfigResolver(
model.ModelId, model.ModelId,
model.BaseUrl, model.BaseUrl,
model.ApiKey, model.ApiKey,
ResolveDefaultInstructions(model.DefaultInstructions), ResolveDefaultInstructions(model.DefaultInstructions, fallback),
model.MaxTokens, model.MaxTokens,
model.Temperature); model.Temperature);
} }
var fallbackModelId = modelType == ModelType.Embedding var fallbackModelId = modelType == ModelType.Embedding
? _fallbackOptions.EmbeddingModel ? fallback.EmbeddingModel
: _fallbackOptions.ChatModel; : fallback.ChatModel;
return new AiModelRuntimeConfig( return new AiModelRuntimeConfig(
fallbackModelId, fallbackModelId,
_fallbackOptions.BaseUrl, fallback.BaseUrl,
_fallbackOptions.ApiKey, fallback.ApiKey,
_fallbackOptions.DefaultInstructions, fallback.DefaultInstructions,
_fallbackOptions.MaxTokens, fallback.MaxTokens,
_fallbackOptions.Temperature); fallback.Temperature);
} }
private string? ResolveDefaultInstructions(string? modelInstructions) private static string? ResolveDefaultInstructions(string? modelInstructions, AiOptions fallback)
{ {
if (string.IsNullOrWhiteSpace(modelInstructions)) if (string.IsNullOrWhiteSpace(modelInstructions))
return _fallbackOptions.DefaultInstructions; return fallback.DefaultInstructions;
return IsLegacyRagOnlyInstructions(modelInstructions) return IsLegacyRagOnlyInstructions(modelInstructions)
? _fallbackOptions.DefaultInstructions ? fallback.DefaultInstructions
: modelInstructions; : modelInstructions;
} }

View File

@ -17,10 +17,10 @@ namespace Workflow.Application.Notifications;
/// </summary> /// </summary>
public class NotificationService( public class NotificationService(
WorkflowDbContext db, WorkflowDbContext db,
IOptions<NotificationOptions> options, IOptionsMonitor<NotificationOptions> options,
IPlatformNotificationPublisher platformPublisher) : INotificationService IPlatformNotificationPublisher platformPublisher) : INotificationService
{ {
private readonly NotificationOptions _options = options.Value; // 配置通过 IOptionsMonitor 实时读取(Scoped,每请求重新 resolve),Consul KV 改动即时生效
public Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default) public Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default)
=> CreateAndDispatchAsync(task, "task-arrived", => CreateAndDispatchAsync(task, "task-arrived",
@ -179,7 +179,7 @@ public class NotificationService(
HttpMethod = "POST", HttpMethod = "POST",
Status = "pending", Status = "pending",
Attempts = 0, Attempts = 0,
MaxAttempts = _options.Webhook.MaxAttempts, MaxAttempts = options.CurrentValue.Webhook.MaxAttempts,
NextRetryAt = now, NextRetryAt = now,
Category = category, Category = category,
RelatedInstanceId = task.InstanceId, RelatedInstanceId = task.InstanceId,
@ -197,7 +197,7 @@ public class NotificationService(
private string ResolveWebhookUrl() private string ResolveWebhookUrl()
{ {
// 当前实现使用全局 DefaultUrl;节点级 webhookUrl 配置可后续扩展(需 ProcessEngine 传入 node.Config) // 当前实现使用全局 DefaultUrl;节点级 webhookUrl 配置可后续扩展(需 ProcessEngine 传入 node.Config)
var url = _options.Webhook.DefaultUrl; var url = options.CurrentValue.Webhook.DefaultUrl;
return string.IsNullOrWhiteSpace(url) ? string.Empty : url; return string.IsNullOrWhiteSpace(url) ? string.Empty : url;
} }
@ -207,14 +207,15 @@ public class NotificationService(
/// </summary> /// </summary>
internal bool IsHostAllowed(string url) 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; return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return false; return false;
var host = uri.Host; var host = uri.Host;
return _options.Webhook.AllowedHosts return webhook.AllowedHosts
.Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase)); .Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase));
} }
} }