using Dpz.Core.EnumLibrary;

namespace Dpz.Core.Web.Jobs.Hangfire.Build;

/// <summary>
/// 探测各发布目标当前运行版本,并与即将发布的版本比较
/// </summary>
public class PipelineVersionProbe(
    IHttpClientFactory httpClientFactory,
    IConfiguration configuration,
    ILogger<PipelineVersionProbe> logger
)
{
    public const string HttpClientName = "pipeline-version";
    public const string WaitCacheKeyPrefix = "pipeline:version-wait:";
    public const string DecisionCacheKeyPrefix = "pipeline:version-decision:";
    public const string DecisionConfirm = "confirm";
    public const string DecisionCancel = "cancel";

    public static readonly TimeSpan ConfirmTimeout = TimeSpan.FromMinutes(2);

    public static string WaitCacheKey(string runId) => $"{WaitCacheKeyPrefix}{runId}";

    public static string DecisionCacheKey(string runId) => $"{DecisionCacheKeyPrefix}{runId}";

    /// <summary>
    /// 找出即将发布版本与线上运行版本相同的项目。
    /// 未配置 host、探测失败的项目会被跳过,不阻断发布。
    /// </summary>
    public async Task<List<PipelineVersionMatch>> FindSameVersionsAsync(
        IReadOnlyDictionary<BuildProject, string> publishVersions,
        CancellationToken cancellationToken = default
    )
    {
        var hosts = ReadVersionHosts();
        var matches = new List<PipelineVersionMatch>();
        foreach (var (project, publishVersion) in publishVersions)
        {
            if (project == BuildProject.None)
            {
                continue;
            }

            if (!hosts.TryGetValue(project.ToString(), out var host))
            {
                logger.LogWarning("未配置 {Project} 的版本探测地址,已跳过同版本检查", project);
                continue;
            }

            var liveVersion = await ProbeLiveVersionAsync(project, host, cancellationToken);
            if (string.IsNullOrWhiteSpace(liveVersion))
            {
                continue;
            }

            if (!IsSameVersion(liveVersion, publishVersion))
            {
                continue;
            }

            matches.Add(
                new PipelineVersionMatch
                {
                    Project = project,
                    Host = host,
                    LiveVersion = liveVersion,
                    PublishVersion = publishVersion,
                }
            );
        }

        return matches;
    }

    private Dictionary<string, string> ReadVersionHosts()
    {
        var section = configuration.GetSection("Pipeline:VersionHosts");
        var hosts = section.Get<Dictionary<string, string>>() ?? [];
        var lookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (key, value) in hosts)
        {
            if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
            {
                continue;
            }

            lookup[key.Trim()] = value.Trim();
        }

        return lookup;
    }

    private async Task<string?> ProbeLiveVersionAsync(
        BuildProject project,
        string host,
        CancellationToken cancellationToken
    )
    {
        if (
            !Uri.TryCreate(host, UriKind.Absolute, out var baseUri)
            || (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps)
        )
        {
            logger.LogWarning(
                "{Project} 的版本探测地址无效,已跳过同版本检查:{Host}",
                project,
                host
            );
            return null;
        }

        var url = new Uri(baseUri, "/api/version");
        try
        {
            var client = httpClientFactory.CreateClient(HttpClientName);
            var raw = (await client.GetStringAsync(url, cancellationToken)).Trim().Trim('"');
            if (string.IsNullOrWhiteSpace(raw))
            {
                logger.LogWarning("{Project} 线上版本为空,已跳过同版本检查:{Url}", project, url);
                return null;
            }

            return raw;
        }
        catch (Exception e)
        {
            logger.LogWarning(e, "探测 {Project} 线上版本失败:{Url}", project, url);
            return null;
        }
    }

    private static bool IsSameVersion(string liveVersion, string publishVersion)
    {
        if (
            Version.TryParse(liveVersion, out var live)
            && Version.TryParse(publishVersion, out var publish)
        )
        {
            return live == publish;
        }

        return string.Equals(liveVersion, publishVersion, StringComparison.OrdinalIgnoreCase);
    }
}

/// <summary>
/// 用户未确认或超时未确认同版本发布
/// </summary>
public sealed class PipelineVersionNotConfirmedException(string message) : Exception(message);
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这段代码是 .NET 中的一个服务类,用于在发布流水线中检测各个发布目标当前正在运行的版本,并与即将发布的版本进行比较,从而判断是否存在“重复发布”的情况。下面详细解释各组成部分。

1. 类的基本结构

public class PipelineVersionProbe(
    IHttpClientFactory httpClientFactory,
    IConfiguration configuration,
    ILogger<PipelineVersionProbe> logger
)
  • 这是 C# 12 的主构造函数语法,依赖注入时会自动传入:
    • IHttpClientFactory:用于创建 HTTP 客户端,探测线上版本接口。
    • IConfiguration:读取配置(如版本探测地址)。
    • ILogger<PipelineVersionProbe>:日志记录器。

类本身被设计为通过依赖注入使用,无额外状态。

2. 常量与静态成员

这些常量用于缓存键和消息标识:

  • HttpClientName = "pipeline-version":HTTP 客户端名称,可供 IHttpClientFactory 创建特定配置的客户端。
  • WaitCacheKeyPrefixDecisionCacheKeyPrefix:用于在缓存中记录等待确认和用户决策的键前缀。
  • DecisionConfirm = "confirm"DecisionCancel = "cancel":表示用户确认或取消同版本发布的决策值。
  • ConfirmTimeout = TimeSpan.FromMinutes(2):等待用户确认的超时时间。
  • 两个静态方法 WaitCacheKeyDecisionCacheKey 用来构造带有特定 runId 的缓存键。

这些缓存键通常与 Hangfire 或外部状态存储配合使用,用于协调发布流程中的人工确认步骤。

3. 主要方法:FindSameVersionsAsync

这是类的核心逻辑,作用是找出“即将发布的版本”与“线上运行版本”相同的项目。

public async Task<List<PipelineVersionMatch>> FindSameVersionsAsync(
    IReadOnlyDictionary<BuildProject, string> publishVersions,
    CancellationToken cancellationToken = default
)
  • publishVersions:要发布的项目及其版本号(BuildProject 是枚举,string 为版本号)。
  • 返回值:List<PipelineVersionMatch>,用于保存已匹配上的项目信息(项目名、Host、线上版本、发布版本)。

执行流程

  1. 读取配置中的版本探测地址(ReadVersionHosts)。
  2. 遍历待发布项目列表。
  3. 跳过 BuildProject.None(无效项目)。
  4. 如果项目没有配置探测地址,则记录警告并跳过(不阻断发布)。
  5. 尝试探测线上版本(ProbeLiveVersionAsync)。
    • 如果探测失败或返回空值,跳过该项目(也不阻断发布)。
  6. 比较线上版本和发布版本是否一致(IsSameVersion)。
  7. 相同则把信息加入 matches 列表。

这个方法的设计思路是宽容处理异常:探测失败、未配置等都不会直接导致发布失败,只会跳过同版本检查;只有明确发现相同版本时才会返回结果,供上层决定是否阻断或要求确认。

4. 辅助方法:ReadVersionHosts

private Dictionary<string, string> ReadVersionHosts()
  • 从配置节 "Pipeline:VersionHosts" 读取一个字典,例如:
{
  "Pipeline": {
    "VersionHosts": {
      "WebApp": "https://example.com"
    }
  }
}
  • 对读取到的字典做规范化:忽略大小写、去除键和值的空格,过滤空值。
  • 返回一个 StringComparer.OrdinalIgnoreCase 比较器的字典,便于后续按项目名查找。

5. 辅助方法:ProbeLiveVersionAsync

private async Task<string?> ProbeLiveVersionAsync(
    BuildProject project,
    string host,
    CancellationToken cancellationToken
)
  • 验证 host 是否为合法的绝对 HTTP/HTTPS URL,否则记录警告并返回 null
  • 拼接 /api/version 作为探测接口。
  • 通过 httpClientFactory.CreateClient(HttpClientName) 获取 HTTP 客户端。
  • 执行 GET 请求,获取字符串响应。
  • 对响应做 .Trim().Trim('"'),去掉首尾空格和可能的引号(很多版本接口可能返回带引号的 JSON 字符串,例如 "1.2.3")。
  • 如果响应为空,记录警告并返回 null
  • 如果请求异常,记录警告并返回 null(不抛出异常,保证主流程不中断)。

这是一个典型的“尽力而为”探测方法:任何失败都视为无法获得线上版本,从而不参与同版本判断。

6. 辅助方法:IsSameVersion

private static bool IsSameVersion(string liveVersion, string publishVersion)
  • 首先尝试用 Version.TryParse 解析两个版本号,如果都能解析成功,则比较两个 Version 对象的相等性(例如 1.0.01.0.0.0 会被视为相同)。
  • 如果其中任意一个无法解析为 Version,则退化为忽略大小写的字符串比较(例如 v1.0.0V1.0.0 会被视为相同)。

这种策略兼顾了“语义化版本号”和“非标准版本号”两种情况。

7. 异常类型:PipelineVersionNotConfirmedException

public sealed class PipelineVersionNotConfirmedException(string message) : Exception(message);
  • 这是一个自定义异常,用于表示“用户未确认或超时未确认同版本发布”。
  • 主构造函数接收错误信息并传给基类 Exception
  • 该异常可能在发布流程中使用,当发现某些项目存在同版本且需要用户确认时,若用户未确认或超过 ConfirmTimeout,则抛出此异常终止或暂停发布。

总结

这个类是一个发布流水线中的“版本探测”组件,职责是:

  • 从配置读取各项目对应的线上探测 URL。
  • 并发或顺序地探测线上当前版本。
  • 与即将发布的版本进行比较,找出哪些项目实际上已经运行了相同版本。
  • 对于探测失败或未配置的项目,采取“跳过”策略,不阻断发布。
  • 返回匹配列表,供上层决定是否要求用户确认或直接终止发布。

代码风格简洁、健壮,充分考虑了各种边界情况(无效 URL、空响应、解析异常、大小写差异等),并利用 .NET 的类型和依赖注入特性实现清晰的职责分离。

评论加载中...