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:
parent
74e99850e5
commit
f43e35a4b1
@ -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>
|
||||||
|
|||||||
@ -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 =>
|
||||||
|
|||||||
@ -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 范围),
|
||||||
|
|||||||
@ -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 索引或冗余列)
|
||||||
|
|||||||
@ -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 崩溃进程。
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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));
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user