节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
15.76 KiB
Stardust
using NewLife;
using NewLife.Log;
using NewLife.Serialization;
using NewLife.Threading;
using Stardust.Data.Nodes;
using Stardust.Models;

namespace Stardust.Server.Services;

/// <summary>定期从Microsoft官网同步.NET运行时安装包信息到DotNetPackage表</summary>
public class DotNetSyncService : IHostedService
{
    private readonly ITracer _tracer;
    private readonly StarServerSetting _setting;
    private TimerX _timer;
    private Int32 _lastPeriod;
    private readonly HttpClient _http;

    public DotNetSyncService(ITracer tracer, StarServerSetting setting)
    {
        _tracer = tracer;
        _setting = setting;
        _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
    }

    /// <summary>启动服务,初始化定时器。不管配置是否禁用都启动,DoSync 内判断配置</summary>
    /// <param name="cancellationToken">取消令牌</param>
    /// <returns>任务</returns>
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // 不管配置是否禁用,都启动定时器。DoSync内判断配置,每次执行后更新周期
        var period = _setting.DotNetSyncPeriod;
        // 初始周期直接用配置值(默认43200秒=12小时),禁用时退回到60秒占位
        var initPeriod = period > 0 ? period * 1000 : 60_000;
        _timer = new TimerX(DoSync, null, 5_000, initPeriod)
        {
            Async = true,
        };
        _lastPeriod = period;

        return Task.CompletedTask;
    }

    /// <summary>停止服务,销毁定时器</summary>
    /// <param name="cancellationToken">取消令牌</param>
    /// <returns>任务</returns>
    public Task StopAsync(CancellationToken cancellationToken)
    {
        _timer.TryDispose();
        return Task.CompletedTask;
    }

    /// <summary>更新定时器周期,根据当前配置动态调整</summary>
    private void UpdateTimer()
    {
        var period = _setting.DotNetSyncPeriod;
        if (_lastPeriod == period) return;
        _lastPeriod = period;

        if (period <= 0)
        {
            // 禁用时改为每分钟检查,确保配置变化后能快速恢复
            _timer.Period = 60_000;
            XTrace.WriteLine("DotNetSyncService is paused, configured period={0}", period);
        }
        else
        {
            _timer.Period = period * 1000;
            XTrace.WriteLine("DotNetSyncService period updated to {0} second(s)", period);
        }
    }

    /// <summary>执行同步。扫描多个主版本的 .NET 运行时信息,从官方源拉取并更新到本地</summary>
    /// <param name="state">定时器状态参数</param>
    private async void DoSync(Object state)
    {
        var period = _setting.DotNetSyncPeriod;
        if (period <= 0)
        {
            // 禁用时不执行同步,但每分钟会进来检查配置变化
            UpdateTimer();
            return;
        }

        var url = _setting.DotNetSyncUrl;
        if (url.IsNullOrEmpty()) return;

        using var span = _tracer?.NewSpan(nameof(DotNetSyncService));
        try
        {
            // 扫描多个主版本
            var majors = new[] { 6, 7, 8, 9, 10 };
            foreach (var major in majors)
            {
                var u = url.Replace("{major}", major + "");
                await SyncMajor(major + ".0", u, span);
            }
        }
        catch (Exception ex)
        {
            span?.SetError(ex, null);
            XTrace.Log.Error("DotNetSyncService.Error {0}", ex.Message);
        }
        finally
        {
            // 每次执行完后检查配置有无变化,动态更新定时器周期
            UpdateTimer();
        }
    }

    /// <summary>同步指定主版本的 .NET 运行时信息</summary>
    /// <param name="versionPrefix">版本前缀,如 "6.0"</param>
    /// <param name="url">官方发布 API 地址</param>
    /// <param name="span">跟踪跨度</param>
    private async Task SyncMajor(String versionPrefix, String url, ISpan span)
    {
        XTrace.WriteLine("DotNetSyncService.Sync {0}", url);

        var json = await _http.GetStringAsync(url);
        var js = JsonParser.Decode(json);
        var releases = js["releases"] as IList<Object>;
        if (releases == null) return;

        // 预查当前大版本下所有 SDK 记录,供 SyncSdk 复用,避免循环内反复查库
        var sdkPkgs = DotNetPackage.FindAll(DotNetPackage._.Kind == "sdk" &
            DotNetPackage._.Version.StartsWith(versionPrefix));

        foreach (var item in releases)
        {
            var rel = item as IDictionary<String, Object>;
            if (rel == null) continue;

            var ver = rel["release-version"] + "";
            // 跳过预览版和RC版
            if (ver.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
                ver.Contains("rc", StringComparison.OrdinalIgnoreCase)) continue;

            // 处理 aspnetcore-runtime → Kind=aspnet
            var allPkgs = DotNetPackage.FindAllByVersion(ver);
            SyncRuntime(rel, "aspnetcore-runtime", "aspnet", ver, allPkgs);
            // 处理 runtime → Kind=runtime(dotnet-runtime 基础运行时)
            SyncRuntime(rel, "runtime", "runtime", ver, allPkgs);
            // 处理 windowsdesktop → Kind=desktop
            SyncRuntime(rel, "windowsdesktop", "desktop", ver, allPkgs);
            // dotnet-hosting 不在顶级 key 中,而是在 aspnetcore-runtime.files 的最后一项,由 SyncRuntime 内识别

            // 处理 sdk(单个主 SDK,如 10.0.301)→ Kind=sdk
            SyncSdk(rel["sdk"] as IDictionary<String, Object>, sdkPkgs);
            // 处理 sdks(多个 SDK 版本数组,如 [10.0.301, 10.0.109])→ Kind=sdk
            var sdks = rel["sdks"] as IList<Object>;
            if (sdks != null)
            {
                foreach (var s in sdks)
                    SyncSdk(s as IDictionary<String, Object>, sdkPkgs);
            }
        }
    }

    private void SyncRuntime(IDictionary<String, Object> rel, String key, String kind, String version,
        IList<DotNetPackage> allPkgs)
    {
        if (!rel.TryGetValue(key, out var v) || v == null) return;

        // Microsoft JSON 结构: { "aspnetcore-runtime": { "files": [...] } }
        var runtime = v as IDictionary<String, Object>;
        if (runtime == null) return;

        var files = runtime["files"] as IList<Object>;
        if (files == null) return;

        foreach (var item in files)
        {
            var file = item as IDictionary<String, Object>;
            if (file == null) continue;

            var name = file["name"] + "";
            var url = file["url"] + "";
            var rid = file["rid"] + "";
            var hash = file["hash"] + "";

            if (name.IsNullOrEmpty() || url.IsNullOrEmpty()) continue;

            // 优先从 URL 截取文件名(JSON name 字段可能缺版本号,如 dotnet-hosting-win.exe)
            var idx = url.LastIndexOf('/');
            if (idx >= 0 && idx < url.Length - 1)
            {
                var urlName = url.Substring(idx + 1);
                if (!urlName.IsNullOrEmpty() && urlName.Contains('.'))
                    name = urlName;
            }

            // 过滤:仅保留标准运行时安装包,排除 composite/targeting-pack 等变体
            if (!IsStandardRuntimeFile(name, kind)) continue;

            // 根据文件名确定实际 Kind(dotnet-hosting 嵌套在 aspnetcore-runtime 中,应记为 host)
            var actualKind = name.StartsWith("dotnet-hosting-") ? "host" : kind;

            // 解析 RID 为 OSKind + CpuArch
            var (os, arch) = ParseRid(rid);

            // dotnet-hosting-win.exe 的 rid 为空字符串,根据文件名推断为 Windows/Any
            if (os == 0 && name.StartsWith("dotnet-hosting-"))
            {
                if (name.Contains("win", StringComparison.OrdinalIgnoreCase))
                {
                    os = (Int32)OSKind.Windows;
                    arch = (Int32)CpuArch.Any;
                }
            }

            if (os == 0) continue;

            // Windows 上跳过 .zip(只保留 .exe 安装包)
            if (os == (Int32)OSKind.Windows && name.EndsWithIgnoreCase(".zip")) continue;

            // 查出所有匹配记录(allPkgs 是版本维度的快照,此处做 Kind+OS+Arch 维度的去重)
            var matched = allPkgs.Where(e =>
                e.Kind == actualKind &&
                e.OSKind == (OSKind)os &&
                e.Architecture == (CpuArch)arch).ToList();

            if (matched.Count == 0)
            {
                // 无记录 → 新建
                var pkg = new DotNetPackage
                {
                    Version = version,
                    Kind = actualKind,
                    OSKind = (OSKind)os,
                    Architecture = (CpuArch)arch,
                    FileName = name,
                    Source = url,
                    FileHash = hash,
                    Enable = true,
                    Channel = NodeChannels.Release,
                    Force = false,
                };
                pkg.Insert();
                allPkgs.Add(pkg);
            }
            else
            {
                // 同 (Version, Kind, OS, Arch) 有多条 → 保留最老一条(最小 ID),删其余重复
                var keep = matched.OrderBy(e => e.Id).First();
                foreach (var dup in matched)
                {
                    if (dup == keep) continue;
                    dup.Delete();
                    allPkgs.Remove(dup);
                }

                // 手动上传的记录(Source 指向 Cube 附件)不被自动同步覆盖
                if (!keep.Source.StartsWith("/cube/file"))
                {
                    // 仅字段实际变化才更新,避免 XCode 拦截器触发无意义 UPDATE
                    if (keep.FileName != name || keep.Source != url || keep.FileHash != hash)
                    {
                        keep.FileName = name;
                        keep.Source = url;
                        keep.FileHash = hash;
                        keep.Update();
                    }
                }
            }
        }
    }

    /// <summary>同步 SDK 安装包(单个 SDK 对象或 sdks 数组中的每个元素)</summary>
    /// <param name="sdkObj">sdk 对象,含 version / files 字段</param>
    /// <param name="sdkPkgs">当前大版本下所有 SDK 记录列表(避免循环内反复查库)</param>
    private void SyncSdk(IDictionary<String, Object> sdkObj, IList<DotNetPackage> sdkPkgs)
    {
        if (sdkObj == null) return;

        var sdkVersion = sdkObj["version"] + "";
        if (sdkVersion.IsNullOrEmpty()) return;

        var files = sdkObj["files"] as IList<Object>;
        if (files == null) return;

        foreach (var item in files)
        {
            var file = item as IDictionary<String, Object>;
            if (file == null) continue;

            var name = file["name"] + "";
            var url = file["url"] + "";
            var rid = file["rid"] + "";
            var hash = file["hash"] + "";

            if (name.IsNullOrEmpty() || url.IsNullOrEmpty()) continue;

            // 优先从 URL 截取文件名
            var idx = url.LastIndexOf('/');
            if (idx >= 0 && idx < url.Length - 1)
            {
                var urlName = url.Substring(idx + 1);
                if (!urlName.IsNullOrEmpty() && urlName.Contains('.'))
                    name = urlName;
            }

            // 过滤:仅保留 dotnet-sdk-* 安装包
            if (!IsStandardRuntimeFile(name, "sdk")) continue;

            // 解析 RID
            var (os, arch) = ParseRid(rid);
            if (os == 0) continue;

            // Windows 上跳过 .zip(只保留 .exe 安装包)
            if (os == (Int32)OSKind.Windows && name.EndsWithIgnoreCase(".zip")) continue;

            // 按 SDK 版本号查已有 SDK 记录(SDK 版本号作 Version)
            var matched = sdkPkgs.Where(e =>
                e.Version == sdkVersion &&
                e.Kind == "sdk" &&
                e.OSKind == (OSKind)os &&
                e.Architecture == (CpuArch)arch).ToList();

            if (matched.Count == 0)
            {
                // 无记录 → 新建
                var pkg = new DotNetPackage
                {
                    Version = sdkVersion,
                    Kind = "sdk",
                    OSKind = (OSKind)os,
                    Architecture = (CpuArch)arch,
                    FileName = name,
                    Source = url,
                    FileHash = hash,
                    Enable = true,
                    Channel = NodeChannels.Release,
                    Force = false,
                };
                pkg.Insert();
                sdkPkgs.Add(pkg);
            }
            else
            {
                // 同 (SDK Version, Kind, OS, Arch) 有多条 → 保留最老一条,删其余重复
                var keep = matched.OrderBy(e => e.Id).First();
                foreach (var dup in matched)
                {
                    if (dup == keep) continue;
                    dup.Delete();
                    sdkPkgs.Remove(dup);
                }

                // 手动上传的记录不被自动同步覆盖
                if (!keep.Source.StartsWith("/cube/file"))
                {
                    // 仅字段实际变化才更新
                    if (keep.FileName != name || keep.Source != url || keep.FileHash != hash)
                    {
                        keep.FileName = name;
                        keep.Source = url;
                        keep.FileHash = hash;
                        keep.Update();
                    }
                }
            }
        }
    }

    /// <summary>判断文件是否为标准运行时安装包(排除 composite/targeting-pack 等变体)</summary>
    /// <remarks>
    /// 注意:dotnet-hosting-win.exe 不在独立的 "dotnet-hosting" 顶级 key 中,
    /// 而是作为最后一项出现在 "aspnetcore-runtime".files 数组内。
    /// 因此处理 "aspnet" kind 时也需要识别 dotnet-hosting-* 前缀。
    /// </remarks>
    private static Boolean IsStandardRuntimeFile(String name, String kind)
    {
        return kind switch
        {
            // aspnetcore-runtime 下:标准运行时 + 嵌套的 dotnet-hosting(排除 composite/targeting)
            "aspnet" => (name.StartsWith("aspnetcore-runtime-") &&
                         !name.Contains("composite") &&
                         !name.Contains("targeting")) ||
                        name.StartsWith("dotnet-hosting-"),
            // runtime 下:dotnet-runtime-*(排除 dotnet-apphost-pack-*)
            "runtime" => name.StartsWith("dotnet-runtime-"),
            "desktop" => name.StartsWith("windowsdesktop-runtime-"),
            "host" => name.StartsWith("dotnet-hosting-"),
            "sdk" => name.StartsWith("dotnet-sdk-"),
            _ => false,
        };
    }

    /// <summary>解析RID为OSKind和CpuArch</summary>
    private static (Int32 os, Int32 arch) ParseRid(String rid)
    {
        if (rid.IsNullOrEmpty()) return (0, 0);

        var os = 0;
        var arch = 0;

        // OS 判断
        if (rid.StartsWithIgnoreCase("win")) os = (Int32)OSKind.Windows;
        else if (rid.StartsWithIgnoreCase("linux-musl")) os = (Int32)OSKind.LinuxMusl;
        else if (rid.StartsWithIgnoreCase("linux")) os = (Int32)OSKind.Linux;
        else if (rid.StartsWithIgnoreCase("osx")) os = (Int32)OSKind.OSX;

        // Arch 判断
        if (rid.EndsWithIgnoreCase("-x64")) arch = (Int32)CpuArch.X64;
        else if (rid.EndsWithIgnoreCase("-x86")) arch = (Int32)CpuArch.X86;
        else if (rid.EndsWithIgnoreCase("-arm64")) arch = (Int32)CpuArch.Arm64;
        else if (rid.EndsWithIgnoreCase("-arm")) arch = (Int32)CpuArch.Arm;

        return (os, arch);
    }
}