diff --git a/src/BuildFlow.Host/BuildFlow.Host.csproj b/src/BuildFlow.Host/BuildFlow.Host.csproj
index 23bcf6d..1358abc 100644
--- a/src/BuildFlow.Host/BuildFlow.Host.csproj
+++ b/src/BuildFlow.Host/BuildFlow.Host.csproj
@@ -54,8 +54,7 @@
-
-
+
diff --git a/src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs b/src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs
new file mode 100644
index 0000000..f4fc0ce
--- /dev/null
+++ b/src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs
@@ -0,0 +1,181 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace BuildFlow.Host.Configuration;
+
+///
+/// 轻量 Consul KV 配置 Provider(自实现,替代 Winton.Extensions.Configuration.Consul)。
+/// 启动时用 Consul HTTP API 拉取 prefix 下所有 KV,扁平化注入 IConfiguration。
+/// 支持 ReloadOnChange(后台 long-poll watch)。
+///
+/// 与 Winton 的区别:完全可控、有日志、不黑盒;用 HttpClient 直接调 /v1/kv/?recurse=true。
+///
+/// KV key 映射:Consul key 去掉 root 前缀后,剩余部分直接作为 IConfiguration key。
+/// 如 Consul key "buildflow/Jwt:SigningKey",root="buildflow/",剩余 "Jwt:SigningKey" → IConfiguration key "Jwt:SigningKey"。
+///
+internal sealed class ConsulConfigurationSource : IConfigurationSource
+{
+ private readonly ConsulOptions _options;
+
+ public ConsulConfigurationSource(ConsulOptions options) => _options = options;
+
+ public IConfigurationProvider Build(IConfigurationBuilder builder) =>
+ new ConsulConfigurationProvider(_options);
+}
+
+internal sealed class ConsulOptions
+{
+ public required string Host { get; init; } // 如 http://43.134.235.58:8520
+ public required string Token { get; init; } // ACL token
+ public required string KeyPrefix { get; init; } // 如 buildflow/
+ public bool Optional { get; init; } = true;
+ public bool ReloadOnChange { get; init; } = true;
+ public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(30);
+}
+
+internal sealed class ConsulConfigurationProvider : ConfigurationProvider, IDisposable
+{
+ private readonly ConsulOptions _options;
+ private readonly HttpClient _http;
+ private readonly ILoggerFactory? _loggerFactory;
+ private readonly ILogger _logger;
+ private ulong _lastIndex;
+ private Timer? _watchTimer;
+ private bool _disposed;
+
+ public ConsulConfigurationProvider(ConsulOptions options)
+ {
+ _options = options;
+ _http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ if (!string.IsNullOrEmpty(options.Token))
+ _http.DefaultRequestHeaders.Add("X-Consul-Token", options.Token);
+ _loggerFactory = LoggerFactory.Create(b => b.AddConsole());
+ _logger = _loggerFactory.CreateLogger();
+ }
+
+ public override void Load()
+ {
+ try
+ {
+ var (dict, index) = FetchKvAsync(waitForChange: false).GetAwaiter().GetResult();
+ _lastIndex = index;
+ Data = dict;
+ _logger.LogInformation("[Consul] 加载成功:{Count} 个 KV,LastIndex={Index}", dict.Count, index);
+ if (dict.Count > 0)
+ {
+ foreach (var k in dict.Keys.Take(3))
+ _logger.LogDebug("[Consul] 示例 key: {Key} = {Val}", k, dict[k]?.ToString()?.Substring(0, Math.Min(20, dict[k]?.ToString()?.Length ?? 0)));
+ }
+
+ if (_options.ReloadOnChange && _watchTimer == null)
+ {
+ _watchTimer = new Timer(_ => WatchCallback(), null, _options.PollInterval, _options.PollInterval);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "[Consul] 加载失败(Optional={Optional}): {Message}", _options.Optional, ex.Message);
+ if (!_options.Optional) throw;
+ }
+ }
+
+ private async void WatchCallback()
+ {
+ if (_disposed) return;
+ try
+ {
+ var (dict, index) = await FetchKvAsync(waitForChange: true);
+ if (index > _lastIndex)
+ {
+ _lastIndex = index;
+ Data = dict;
+ OnReload();
+ _logger.LogInformation("[Consul] 热重载:检测到变更(LastIndex={Index}),已刷新 {Count} 个 KV", index, dict.Count);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "[Consul] watch 轮询异常(将继续重试): {Message}", ex.Message);
+ }
+ }
+
+ private async Task<(Dictionary Dict, ulong Index)> FetchKvAsync(bool waitForChange)
+ {
+ // Consul KV recurse API:返回 prefix 下所有 KV
+ var url = $"{_options.Host}/v1/kv/{_options.KeyPrefix}?recurse=true";
+ using var resp = await _http.GetAsync(url);
+
+ if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Consul 返回 404:无此 key(首次未灌入)。Optional 时视为空配置。
+ var idx = ParseIndex(resp.Headers);
+ return (new Dictionary(), idx);
+ }
+ resp.EnsureSuccessStatusCode();
+
+ var kvs = await resp.Content.ReadFromJsonAsync>()
+ ?? new List();
+ var lastIndex = ParseIndex(resp.Headers);
+
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var kv in kvs)
+ {
+ // key 去掉 prefix,剩余直接作为 IConfiguration key(Consul key 里用 : 表层级)
+ var configKey = kv.Key;
+ if (configKey.StartsWith(_options.KeyPrefix, StringComparison.OrdinalIgnoreCase))
+ configKey = configKey[_options.KeyPrefix.Length..];
+ // Consul key 可能用 / 分隔层级,转成 IConfiguration 的 :
+ configKey = configKey.Replace('/', ':');
+ // value 是 base64 编码的,Consul API 默认返回 base64
+ var value = DecodeValue(kv.Value);
+ dict[configKey] = value;
+ }
+ return (dict, lastIndex);
+ }
+
+ private static ulong ParseIndex(HttpResponseHeaders headers)
+ {
+ if (headers.TryGetValues("X-Consul-Index", out var vals))
+ {
+ var s = vals.FirstOrDefault();
+ if (ulong.TryParse(s, out var idx)) return idx;
+ }
+ return 0;
+ }
+
+ // Consul KV API 的 Value 字段是 base64 编码的(recurse 接口默认)
+ private static string? DecodeValue(string? b64)
+ {
+ if (string.IsNullOrEmpty(b64)) return null;
+ try
+ {
+ var bytes = Convert.FromBase64String(b64);
+ return System.Text.Encoding.UTF8.GetString(bytes);
+ }
+ catch
+ {
+ return b64; // 非 base64,原样返回
+ }
+ }
+
+ public void Dispose()
+ {
+ _disposed = true;
+ _watchTimer?.Dispose();
+ _http.Dispose();
+ _loggerFactory?.Dispose();
+ }
+}
+
+// Consul KV API 响应项(recurse 接口)
+internal sealed class ConsulKvItem
+{
+ public int CreateIndex { get; set; }
+ public int ModifyIndex { get; set; }
+ public string Key { get; set; } = "";
+ public int LockIndex { get; set; }
+ public string? Value { get; set; }
+ public int Flags { get; set; }
+}
diff --git a/src/BuildFlow.Host/Program.cs b/src/BuildFlow.Host/Program.cs
index e79b51a..61ca650 100644
--- a/src/BuildFlow.Host/Program.cs
+++ b/src/BuildFlow.Host/Program.cs
@@ -1,8 +1,6 @@
using BuildFlow.Host;
using BuildFlow.Host.Configuration;
using OpenTelemetry.Logs;
-using Winton.Extensions.Configuration.Consul;
-using Winton.Extensions.Configuration.Consul.Parsers;
var builder = WebApplication.CreateBuilder(args);
@@ -11,37 +9,22 @@ var builder = WebApplication.CreateBuilder(args);
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,不阻断启动。
+// 自实现 Provider(替代 Winton.Extensions.Configuration.Consul),用 HTTP API 拉 buildflow/ 下所有 KV。
+// 鸡生蛋问题:Consul 地址用环境变量 Consul__Host 注入,默认回退 middleware 公网地址。
var consulHost = builder.Configuration["Consul:Host"] ?? "http://43.134.235.58:8520";
var consulToken = builder.Configuration["Consul:Token"] ?? "";
-builder.Configuration.AddConsul(
- "buildflow/",
- options =>
+if (!string.IsNullOrEmpty(consulToken))
+{
+ builder.Configuration.Add(new ConsulConfigurationSource(new ConsulOptions
{
- options.ConsulConfigurationOptions = co =>
- {
- co.Address = new Uri(consulHost);
- // ACL token:Consul 开启了 ACL(default deny),api 必须带专用 token 才能读写 KV
- // token 通过环境变量 Consul:Token 注入(compose),不进代码/镜像
- if (!string.IsNullOrEmpty(consulToken))
- co.Token = consulToken;
- };
- // 用 SimpleConfigurationParser:每个 Consul KV 的 value 作为字符串值(非 JSON)。
- // 默认 ConvertConsulKVPairToConfig 把 Consul key 路径(去 buildflow/ 前缀,/ 转 :)作为 IConfiguration key,
- // 如 buildflow/Jwt:SigningKey → 配置 Jwt:SigningKey,value 直接是密钥字符串。
- options.Parser = new SimpleConfigurationParser();
- options.Optional = false; // 诊断期:强制 Consul 必须可用,暴露真实错误
- options.ReloadOnChange = true; // KV 变更自动刷新 IConfiguration(热重载基础)
- // 诊断期:打印 Consul 加载异常(确认后改回 Ignore)
- options.OnLoadException = ex =>
- {
- Console.WriteLine($"[Consul] 加载异常: {ex.Exception?.GetType().Name}: {ex.Exception?.Message}");
- Console.WriteLine($"[Consul] 堆栈: {ex.Exception?.StackTrace?.Substring(0, System.Math.Min(500, ex.Exception?.StackTrace?.Length ?? 0))}");
- ex.Ignore = false; // 不吞,让异常抛出
- };
- });
+ Host = consulHost,
+ Token = consulToken,
+ KeyPrefix = "buildflow/",
+ Optional = true, // Consul 不可用回退本地 json,不阻断启动
+ ReloadOnChange = true, // 后台 watch KV 变更,热重载
+ PollInterval = TimeSpan.FromSeconds(30)
+ }));
+}
// OTel 日志导出
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4316";