节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
32.13 KiB
Stardust
using NewLife;
using NewLife.Caching;
using NewLife.Log;
using NewLife.Remoting;
using NewLife.Remoting.Extensions.Services;
using NewLife.Remoting.Models;
using NewLife.Security;
using NewLife.Serialization;
using Stardust.Data;
using Stardust.Data.Configs;
using Stardust.Data.Nodes;
using Stardust.Data.Platform;
using Stardust.Models;
using XCode;
using Service = Stardust.Data.Service;

namespace Stardust.Server.Services;

/// <summary>注册服务。处理应用注册、登录认证、服务注册与发现的核心服务</summary>
public class RegistryService : DefaultDeviceService<Node, NodeOnline>
{
    private readonly AppOnlineService _appOnline;
    private readonly IPasswordProvider _passwordProvider;
    private readonly AppSessionManager _sessionManager;
    private readonly ICacheProvider _cacheProvider;
    private readonly StarServerSetting _setting;
    private readonly ITracer _tracer;

    /// <summary>实例化注册服务</summary>
    /// <param name="appOnline">应用在线服务</param>
    /// <param name="passwordProvider">密码提供者</param>
    /// <param name="sessionManager">会话管理器</param>
    /// <param name="cacheProvider">缓存提供者</param>
    /// <param name="setting">服务端设置</param>
    /// <param name="tracer">跟踪器</param>
    /// <param name="serviceProvider">服务提供者</param>
    public RegistryService(AppOnlineService appOnline, IPasswordProvider passwordProvider, AppSessionManager sessionManager, ICacheProvider cacheProvider, StarServerSetting setting, ITracer tracer, IServiceProvider serviceProvider) : base(sessionManager, passwordProvider, cacheProvider, serviceProvider)
    {
        _appOnline = appOnline;
        _passwordProvider = passwordProvider;
        _sessionManager = sessionManager;
        _cacheProvider = cacheProvider;
        _setting = setting;
        _tracer = tracer;

        Name = "App";
    }

    #region 登录注销
    //public override ILoginResponse Login(DeviceContext context, ILoginRequest request, String source)
    //{
    //    var rs = base.Login(context, request, source);

    //    if (context.Online is AppOnline online && request is AppModel inf)
    //    {
    //        // 关联节点,根据NodeCode匹配,如果未匹配上,则在未曾关联节点时才使用IP匹配
    //        var node = Node.FindByCode(inf.NodeCode);
    //        if (node == null && online.NodeId == 0) node = Node.SearchByIP(inf.IP).FirstOrDefault();
    //        if (node != null) online.NodeId = node.ID;

    //        if (!inf.Version.IsNullOrEmpty()) online.Version = inf.Version;
    //        var compile = inf.Compile.ToDateTime().ToLocalTime();
    //        if (compile.Year > 2000) online.Compile = compile;

    //        online.Update();
    //    }

    //    return rs;
    //}

    /// <summary>验证设备合法性</summary>
    public override Boolean Authorize(DeviceContext context, ILoginRequest request)
    {
        if (context.Device is not App app) return false;

        using var span = _tracer?.NewSpan($"{Name}Authorize", new { request.Code, request.ClientId });

        var ip = context.UserHost;

        // 检查黑白名单
        if (!app.MatchIp(ip))
            throw new ApiException(ApiCode.Forbidden, L.T("Application [{0}] forbids access from {1}.", app.Name, ip));
        if (app.Project != null && !app.Project.MatchIp(ip))
            throw new ApiException(ApiCode.Forbidden, L.T("Project [{0}] forbids access from {1}.", app.Project, ip));

        // 检查应用有效性
        if (!app.Enable) throw new ApiException(ApiCode.Forbidden, L.T("Application [{0}] is disabled.", app.Name));

        // 安全治理:禁止空密钥应用匿名登录(认证绕过)。空密钥应用需在注册中心设置密钥,或临时开启"允许重新注册"自动收敛
        if (app.Secret.IsNullOrEmpty())
        {
            app.WriteHistory(L.T("Application authentication"), false, L.T("An application with an empty secret cannot log in. Set a Secret for this application in the registry or enable re-registration."), null, ip, context.ClientId);
            return false;
        }
        if (app.Secret.EqualIgnoreCase(request.Secret)) return true;

        if (_setting.SaltTime > 0 && _passwordProvider is SaltPasswordProvider saltProvider)
        {
            // 使用盐值偏差时间,允许客户端时间与服务端时间有一定偏差
            saltProvider.SaltTime = _setting.SaltTime;
        }
        if (request.Secret.IsNullOrEmpty() || !_passwordProvider.Verify(app.Secret, request.Secret))
        {
            app.WriteHistory(L.T("Application authentication"), false, L.T("Secret validation failed."), null, ip, context.ClientId);
            return false;
        }

        return true;
    }

    /// <summary>自动注册</summary>
    /// <param name="context"></param>
    /// <param name="request"></param>
    /// <returns></returns>
    /// <exception cref="ApiException"></exception>
    public override IDeviceModel Register(DeviceContext context, ILoginRequest request)
    {
        using var span = _tracer?.NewSpan($"{Name}Register", new { request.Code, request.ClientId });

        var name = request.Code;

        App app = null;
        try
        {
            // 空应用标识直接拒绝:GetOrAdd 内部对空键直接解引用,会以 NRE 形式返回 500
            if (name.IsNullOrEmpty()) throw new ApiException(ApiCode.BadRequest, L.T("Application identifier cannot be empty."));

            // 查找应用。若已存在,说明客户端密钥校验失败(错误或为空)后被框架转入动态注册流程。
            // 默认禁止复用旧身份自动放行,防止身份冒用与真实密钥泄露(安全扫描 V-1)
            app = App.FindByName(name);
            if (app != null)
            {
                // 按重注册模式放行:Off=拒绝;Auto=收敛窗口;Trusted=可信接入(同项目节点/近期指纹);Open=开放
                var mode = app.ReRegister;
                if (mode == ReRegisterModes.Off || (mode == ReRegisterModes.Trusted && !IsTrustedClient(app, request)))
                    throw new ApiException(ApiCode.Forbidden, L.T("Application [{0}] already exists; reusing the old identity for dynamic registration is forbidden. Check the secret or contact the administrator. To enable automatic instance onboarding, adjust the application's re-registration mode (convergence window/trusted access/open).", name));

                var model = request as AppModel;
                var uuid = (model != null && !model.UUID.IsNullOrEmpty()) ? model.UUID : null;
                var remark = uuid == null
                    ? L.T("[{0}] re-registered in [{1}] mode; secret issued", app.Name, app.ReRegister)
                    : L.T("[{0}] re-registered in [{1}] mode; secret issued, UUID={2}", app.Name, app.ReRegister, uuid);
                app.WriteHistory(L.T("Application re-registered"), true, remark, null, context.UserHost, context.ClientId);
                context.Device = app;

                return app;
            }

            // 新应用,查找或创建应用,避免多线程创建冲突
            app = App.GetOrAdd(name, App.FindByName, k => new App
            {
                Name = name,
                Secret = Rand.NextString(16),
                Enable = _setting.AppAutoRegister,
            });

            app.WriteHistory(L.T("Application registered"), true, L.T("[{0}] registered successfully.", app.Name), null, context.UserHost, context.ClientId);
            context.Device = app;
        }
        catch (Exception ex)
        {
            span?.SetError(ex, null);

            app?.WriteHistory(L.T("Application registered"), false, L.T("[{0}] registration failed: {1}", app.Name, ex.Message), null, context.UserHost, context.ClientId);

            throw;
        }

        return app;
    }

    /// <summary>可信接入判定。应用实例声明运行于同项目受管节点(NodeCode),或UUID/Macs命中该应用在线指纹时视为可信</summary>
    /// <param name="app">已存在应用</param>
    /// <param name="request">登录请求</param>
    /// <returns>是否可信</returns>
    private Boolean IsTrustedClient(App app, ILoginRequest request)
    {
        if (request is not AppModel inf) return false;

        // 节点门:节点存在、启用且与应用同项目(应用未归属项目时不限制),视为平台认识的受管机器
        if (!inf.NodeCode.IsNullOrEmpty())
        {
            var node = Node.FindByCode(inf.NodeCode);
            if (node != null && node.Enable && (app.ProjectId <= 0 || node.ProjectId == app.ProjectId))
            {
                XTrace.WriteLine("Trusted access for application [{0}]: matched node [{1}] in the same project, IP={2}", app, node, inf.IP);
                return true;
            }
        }

        // 指纹门:UUID/Macs 命中该应用在线会话(同机器或 Macs 交集),覆盖重装/密钥丢失后回归
        if (!inf.UUID.IsNullOrEmpty() || !inf.Macs.IsNullOrEmpty())
        {
            if (AppOnline.IsRecentFingerprint(app.Id, inf.UUID, inf.Macs))
            {
                XTrace.WriteLine("Trusted access for application [{0}]: matched online fingerprint UUID={1}, IP={2}", app, inf.UUID, inf.IP);
                return true;
            }
        }

        return false;
    }

    /// <summary>鉴权后的登录处理。修改设备信息、创建在线记录和写日志</summary>
    /// <param name="context">上下文</param>
    /// <param name="request">登录请求</param>
    public override void OnLogin(DeviceContext context, ILoginRequest request)
    {
        if (context.Device is not App app) return;

        using var span = _tracer?.NewSpan($"{Name}OnLogin", new { request.Code, request.ClientId });

        var model = request as AppModel;
        var ip = context.UserHost;

        // 设置默认项目
        if (app.ProjectId == 0 || app.ProjectName == "默认")
        {
            var project = GalaxyProject.FindByName(model.Project);
            if (project != null)
                app.ProjectId = project.Id;
        }

        if (app.DisplayName.IsNullOrEmpty()) app.DisplayName = model.AppName;

        // 比较编译时间,只要最新的
        var compile = model.Compile.ToDateTime().ToLocalTime();
        if (app.Compile < compile)
        {
            app.Compile = compile;
            app.Version = model.Version;
        }

        if (app.DisplayName.IsNullOrEmpty()) app.DisplayName = model.AppName;
        app.LastLogin = DateTime.Now;
        app.LastIP = ip;
        app.UpdateIP = ip;

        // 密钥配对成功,仅收敛窗口模式(Auto)自动关闭重注册窗口,防止长期开启被冒用;可信接入/开放模式保持长期有效
        if (app.ReRegister == ReRegisterModes.Auto && !app.Secret.IsNullOrEmpty()) app.ReRegister = ReRegisterModes.Off;

        app.Update();

        context.Online = GetOnline(context) ?? CreateOnline(context);

        if (context.Online is AppOnline online && request is AppModel inf)
        {
            // 关联节点,根据NodeCode匹配,如果未匹配上,则在未曾关联节点时才使用IP匹配
            var node = Node.FindByCode(inf.NodeCode);
            if (node == null && online.NodeId == 0) node = Node.SearchByIP(inf.IP).FirstOrDefault();
            if (node != null) online.NodeId = node.ID;

            if (!inf.Version.IsNullOrEmpty()) online.Version = inf.Version;
            //var compile = inf.Compile.ToDateTime().ToLocalTime();
            if (compile.Year > 2000) online.Compile = compile;

            // 记录机器指纹,供可信接入(Trusted)判定近期见过该机器
            if (!inf.UUID.IsNullOrEmpty()) online.Uuid = inf.UUID;
            if (!inf.Macs.IsNullOrEmpty()) online.Macs = inf.Macs;

            online.Update();
        }

        // 登录历史
        WriteHistory(context, L.T("Application authentication"), true, L.T("[{0}/{1}] authenticated {2}", app.DisplayName, app.Name, model.ToJson(false, false, false)));

        // 检查应用上线恢复
        OnlineService.CheckOnline(app);
    }

    /// <summary>注销</summary>
    /// <param name="reason">注销原因</param>
    /// <param name="ip">IP地址</param>
    /// <returns></returns>
    public override IOnlineModel? Logout(DeviceContext context, String? reason, String source)
    {
        //var online = appOnline.GetOnline(context.ClientId);
        //if (online == null) return null;

        //var app = context.Device as App;
        //var msg = $"{reason} [{app}]]登录于{online.CreateTime},最后活跃于{online.UpdateTime}";
        //app.WriteHistory("应用下线", true, msg, context.UserHost);

        ////!! 应用注销,不删除在线记录,保留在线记录用于查询
        ////online.Delete();

        //appOnline.RemoveOnline(context.ClientId);

        var online = base.Logout(context, reason, source);
        if (online is AppOnline online2)
        {
            _appOnline.RemoveOnline(context.ClientId);
        }

        return online;
    }

    #endregion

    #region 服务注册
    public (AppService, Boolean changed) RegisterService(App app, Service service, PublishServiceInfo model, AppOnline online, String ip)
    {
        var clientId = model.ClientId;
        var localIp = clientId;
        if (!localIp.IsNullOrEmpty())
        {
            var p = localIp.IndexOf('@');
            if (p > 0) localIp = localIp[..p];
        }

        // 单例部署服务,每个节点只有一个实例,使用本地IP作为唯一标识,无需进程ID,减少应用服务关联数
        if (service.Singleton) clientId = localIp;

        // 所有服务
        var services = AppService.FindAllByService(service.Id);
        var changed = false;
        var svc = services.FirstOrDefault(e => e.AppId == app.Id && (e.Client == clientId || service.Singleton && !localIp.IsNullOrEmpty() && e.Client.StartsWith($"{localIp}@")));
        if (svc == null)
        {
            svc = new AppService
            {
                AppId = app.Id,
                ServiceId = service.Id,
                ServiceName = model.ServiceName,
                Client = clientId,

                CreateIP = ip,
            };
            services.Add(svc);

            changed = true;
            WriteHistory(app, "RegisterService", true, L.T("Registered service [{0}] {1}", model.ServiceName, model.ClientId), ip, clientId);
        }
        else
        {
            if (!svc.Enable)
            {
                svc.Enable = app.AutoActive;

                if (svc.Enable) changed = true;
            }
        }

        // 节点信息
        //var online = AppOnline.FindByClient(model.ClientId);
        if (online != null) svc.NodeId = online.NodeId;

        // 作用域
        if (service.UseScope)
            svc.Scope = AppRule.CheckScope(-1, ip, localIp);

        svc.Enable = app.AutoActive;
        svc.PingCount++;
        svc.Tag = model.Tag;
        svc.Version = model.Version;
        svc.OriginAddress = model.Address;
        svc.ExternalAddress = model.ExternalAddress;

        // 地址处理。本地任意地址,更换为IP地址
        var serverAddress = "";
        if (service.Extranet && ip != "127.0.0.1" && ip != "::1")
        {
            serverAddress = ip;
        }
        else
        {
            serverAddress = model.IP;
            if (serverAddress.IsNullOrEmpty()) serverAddress = localIp;
            if (serverAddress.IsNullOrEmpty()) serverAddress = ip;
        }

        var urls = new List<String>();
        foreach (var item in serverAddress.Split(","))
        {
            var addrs = model.Address
                ?.Replace("://*", $"://{item}")
                ?.Replace("://+", $"://{item}")
                .Replace("://0.0.0.0", $"://{item}")
                .Replace("://[::]", $"://{item}")
                .Split(",");
            if (addrs != null)
            {
                foreach (var elm in addrs)
                {
                    var url = elm;
                    if (url.StartsWithIgnoreCase("http://", "https://")) url = new Uri(url).ToString().TrimEnd('/');
                    if (!urls.Contains(url)) urls.Add(url);
                }
            }
        }

        if (service.Address.IsNullOrEmpty())
        {
            if (!model.ExternalAddress.IsNullOrEmpty())
                svc.Address = model.ExternalAddress?.Split(',').Take(5).Join(",");
            else
                svc.Address = urls.Take(5).Join(",");
        }
        else
        {
            // 地址模版
            var addr = service.Address.Replace("{LocalIP}", localIp).Replace("{IP}", ip);
            if (addr.Contains("{Port}"))
            {
                var port = 0;
                foreach (var item in urls)
                {
                    var p = item.IndexOf(":", "http://".Length);
                    if (p >= 0)
                    {
                        port = item[(p + 1)..].TrimEnd('/').ToInt();
                        if (port > 0) break;
                    }
                }
                if (port > 0) addr = addr.Replace("{Port}", port + "");
            }
            svc.Address = addr;
        }

        // 无需健康监测,直接标记为健康
        if (!model.Health.IsNullOrEmpty()) service.HealthAddress = model.Health;
        if (!service.HealthCheck || service.HealthAddress.IsNullOrEmpty()) svc.Healthy = true;

        svc.Save();

        service.Providers = services.Count;
        service.Save();

        // 如果有改变,异步监测健康状况
        if (changed && service.HealthCheck && !service.HealthAddress.IsNullOrEmpty())
        {
            _ = Task.Run(() => HealthCheck(svc));
        }

        return (svc, changed);
    }

    /// <summary>服务健康检查。按服务的健康检测地址探测一次,并更新服务的健康状态</summary>
    /// <param name="svc">应用服务实例</param>
    public async Task HealthCheck(AppService svc)
    {
        var url = svc.Service?.HealthAddress;
        if (url.IsNullOrEmpty()) return;

        try
        {
            if (!url.StartsWithIgnoreCase("http://", "https://"))
            {
                var ss = svc.Address.Split(',');
                var uri = new Uri(new Uri(ss[0]), url);
                url = uri.ToString();
            }

            var http = _tracer.CreateHttpClient();
            var rs = await http.GetStringAsync(url);

            svc.Healthy = true;
            svc.CheckResult = rs;
        }
        catch (Exception ex)
        {
            svc.Healthy = false;
            svc.CheckResult = ex.ToString();

            XTrace.WriteLine("HealthCheck: {0}", url);
            XTrace.Log.Error(ex.Message);
        }

        svc.CheckTimes++;
        svc.LastCheck = DateTime.Now;
        svc.Update();
    }

    public (AppService, Boolean changed) UnregisterService(App app, Service info, PublishServiceInfo model, String ip)
    {
        // 单例部署服务,每个节点只有一个实例,使用本地IP作为唯一标识,无需进程ID,减少应用服务关联数
        var clientId = model.ClientId;
        if (info.Singleton && !clientId.IsNullOrEmpty())
        {
            var p = clientId.IndexOf('@');
            if (p > 0) clientId = clientId[..p];
        }

        // 所有服务
        var services = AppService.FindAllByService(info.Id);
        var changed = false;
        var svc = services.FirstOrDefault(e => e.AppId == app.Id && e.Client == clientId);
        if (svc != null)
        {
            //svc.Delete();
            svc.Enable = false;
            svc.Healthy = false;
            svc.Update();

            services.Remove(svc);

            changed = true;
            WriteHistory(app, "UnregisterService", true, L.T("Service [{0}] went offline: {1}", model.ServiceName, svc.Client), ip, svc.Client);
        }

        info.Providers = services.Count;
        info.Save();

        return (svc, changed);
    }

    public ServiceModel[] ResolveService(Service service, ConsumeServiceInfo model, String scope)
    {
        var list = new List<ServiceModel>();
        var tags = model.Tag?.Split(",");

        // 该服务所有生产
        var services = AppService.FindAllByService(service.Id);
        foreach (var item in services)
        {
            // 启用,匹配规则,健康
            if (item.Enable && item.Healthy && item.Match(model.MinVersion, scope, tags))
            {
                list.Add(item.ToModel());
            }
        }

        return list.ToArray();
    }
    #endregion

    #region 心跳保活
    //public override IOnlineModel OnPing(DeviceContext context, IPingRequest request)
    //{
    //    if (context.Device is not App app) return null;

    //    // 更新在线记录
    //    var inf = request as AppInfo;
    //    //var (online, _) = appOnline.GetOnline(app, context.ClientId, context.Token, inf?.IP, context.UserHost);
    //    var online = base.OnPing(context, request) as AppOnline;
    //    if (online != null)
    //    {
    //        //online.Version = app.Version;
    //        online.Fill(app, inf);
    //        online.SaveAsync();
    //    }

    //    //// 保存性能数据
    //    //AppMeter.WriteData(app, inf, "Ping", clientId, ip);

    //    return online;
    //}

    private static Int32 _totalCommands;
    private static IList<AppCommand> _commands = [];
    private static DateTime _nextTime;

    public override CommandModel[]? AcquireCommands(DeviceContext context)
    {
        // 缓存最近1000个未执行命令,用于快速过滤,避免大量节点在线时频繁查询命令表
        if (_nextTime < DateTime.Now || _totalCommands != AppCommand.Meta.Count)
        {
            _totalCommands = AppCommand.Meta.Count;
            _commands = AppCommand.AcquireCommands(-1, 1000);
            _nextTime = DateTime.Now.AddMinutes(1);
        }

        if (context.Device is not App app) return null;
        var appId = app.Id;

        // 是否有本节点
        if (!_commands.Any(e => e.AppId == appId)) return null;

        using var span = _tracer?.NewSpan(nameof(AcquireCommands), new { appId });

        var cmds = AppCommand.AcquireCommands(appId, 100);
        if (cmds.Count == 0) return null;

        var rs = new List<CommandModel>();
        foreach (var item in cmds)
        {
            // 命令要提前下发,在客户端本地做延迟处理,这里不应该过滤掉
            //// 命令是否已经开始
            //if (item.StartTime > DateTime.Now) continue;

            // 带有过期时间的命令,加大重试次数
            var maxTimes = item.Expire.Year > 2000 ? 100 : 10;
            if (item.Times > maxTimes || item.Expire.Year > 2000 && item.Expire < DateTime.Now)
                item.Status = CommandStatus.取消;
            else
            {
                // 如果命令正在处理中,则短期内不重复下发。客户端StarAgent具备去重能力,不需要服务端过滤
                //if (item.Status == CommandStatus.处理中 && item.UpdateTime.AddSeconds(30) > DateTime.Now) continue;

                // 即时指令,或者已到开始时间的未来指令,才增加次数
                if (item.StartTime.Year < 2000 || item.StartTime < DateTime.Now)
                    item.Times++;
                item.Status = CommandStatus.处理中;

                var commandModel = BuildCommand(item.App, item);

                rs.Add(commandModel);
            }
            item.UpdateTime = DateTime.Now;
        }
        cmds.Update(false);

        return rs.ToArray();
    }

    ///// <summary>获取在线。先查缓存再查库</summary>
    ///// <param name="context">上下文</param>
    ///// <returns></returns>
    //public override IOnlineModel GetOnline(DeviceContext context) => base.GetOnline(context) as AppOnline;

    ///// <summary>设置设备的长连接上线/下线</summary>
    ///// <param name="context">上下文</param>
    ///// <param name="online"></param>
    ///// <returns></returns>
    //public override void SetOnline(DeviceContext context, Boolean online)
    //{
    //    if ((GetOnline(context) ?? context.Online) is AppOnline olt)
    //    {
    //        olt.LongLink = online;
    //        olt.Update();
    //    }
    //}
    #endregion

    #region 下行通知
    /// <summary>向应用发送命令</summary>
    /// <param name="app">应用</param>
    /// <param name="clientId">应用实例标识。向特定应用实例发送命令时指定</param>
    /// <param name="model">命令模型</param>
    /// <param name="user">创建者</param>
    /// <returns></returns>
    public async Task<CommandReplyModel> SendCommand(App app, String clientId, CommandInModel model, String user, CancellationToken cancellationToken = default)
    {
        //if (model.Code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Code), L.T("Application must be specified."));
        if (model.Command.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Command));

        var cmd = new AppCommand
        {
            AppId = app.Id,
            Command = model.Command,
            Argument = model.Argument,
            //Expire = model.Expire,
            TraceId = DefaultSpan.Current?.TraceId,

            CreateUser = user,
        };
        if (model.StartTime > 0) cmd.StartTime = DateTime.Now.AddSeconds(model.StartTime);
        if (model.Expire > 0) cmd.Expire = DateTime.Now.AddSeconds(model.Expire);
        cmd.Insert();

        // 分发命令给该应用的所有实例
        var cmdModel = BuildCommand(app, cmd);
        var ts = new List<Task<CommandReplyModel?>>();
        foreach (var item in AppOnline.FindAllByApp(app.Id))
        {
            // 对特定实例发送
            if (!clientId.IsNullOrEmpty() && item.Client != clientId) continue;

            //_queue.Publish(app.Name, item.Client, cmdModel);
            var code = $"{app.Name}@{item.Client}";
            ts.Add(_sessionManager.PublishAsync(code, cmdModel, null, model.Timeout, cancellationToken));
        }

        // 目标应用没有任何在线实例(离线),无法下发命令,直接返回空,避免等待空任务抛空引用异常
        if (ts.Count == 0) return null;

        await Task.WhenAll(ts);

        // SessionManager.PublishAsync 内置timeout等待,取首个非空响应。
        // 即发即忘(timeout<=0)时所有任务结果均为空,FirstOrDefault 会返回空任务,不能直接 await
        foreach (var item in ts)
        {
            if (item.Result != null) return item.Result;
        }

        return null;
    }

    public override Task<CommandReplyModel?> SendCommand(DeviceContext context, CommandInModel model, CancellationToken cancellationToken = default)
    {
        if (context.Device is not App app) return null;

        return SendCommand(app, null, model, null);
    }

    public override Int32 CommandReply(DeviceContext context, CommandReplyModel model)
    {
        var app = context.Device as App;
        var cmd = AppCommand.FindById((Int32)model.Id);
        if (cmd == null) return 0;

        // 防止越权
        if (cmd.AppId != app.Id) throw new ApiException(ApiCode.Forbidden, L.T("[{0}] attempted to access the service of [{1}] without permission", app, cmd.AppName));

        cmd.Status = model.Status;
        cmd.Result = model.Data;
        cmd.Update();

        // 通过会话管理器内置的响应事件总线广播响应(跨实例广播不阻塞)
        _ = _sessionManager.PublishResponseAsync(model, default);

        return 1;
    }

    /// <summary>通知该服务的所有消费者,服务信息有变更</summary>
    /// <param name="service"></param>
    /// <param name="command"></param>
    /// <param name="user"></param>
    public async Task NotifyConsumers(AppService service, String command, String user = null)
    {
        var list = AppConsume.FindAllByService(service.ServiceId);
        if (list.Count == 0) return;

        // 获取所有订阅该服务的应用,可能相同应用多实例订阅,需要去重
        var appIds = list.Select(e => e.AppId).Distinct().ToArray();
        var arguments = new { service.AppName, service.ServiceName, service.Address }.ToJson();

        using var span = _tracer?.NewSpan(nameof(NotifyConsumers), $"{command} appIds={appIds.Join()} user={user} arguments={arguments}");

        var ts = new List<Task>();
        foreach (var item in appIds)
        {
            var app = App.FindById(item);
            if (app != null)
            {
                var model = new CommandInModel
                {
                    Command = command,
                    Argument = arguments,
                    Expire = 600,
                };
                ts.Add(SendCommand(app, null, model, user));
            }
        }

        await Task.WhenAll(ts);
    }

    //private static Version _version = new(3, 1, 2025, 0103);
    private CommandModel BuildCommand(App app, AppCommand cmd)
    {
        var model = cmd.ToModel();
        model.TraceId = DefaultSpan.Current + "";

        // 新版本使用UTC时间
        if (app.Compile.Year >= 2025)
        {
            if (model.StartTime.Year > 2000)
                model.StartTime = model.StartTime.ToUniversalTime();
            if (model.Expire.Year > 2000)
                model.Expire = model.Expire.ToUniversalTime();
        }

        return model;
    }
    #endregion

    #region 事件上报
    //public Int32 PostEvents(DeviceContext context, EventModel[] events)
    //{
    //    var app = context.Device as App;
    //    var ip = context.UserHost;
    //    var olt = AppOnline.FindByClient(clientId);
    //    var his = new List<AppHistory>();
    //    foreach (var model in events)
    //    {
    //        //WriteHistory(model.Name, !model.Type.EqualIgnoreCase("error"), model.Time.ToDateTime().ToLocalTime(), model.Remark, null);
    //        var success = !model.Type.EqualIgnoreCase("error");
    //        var time = model.Time.ToDateTime().ToLocalTime();
    //        var hi = AppHistory.Create(app, model.Name, success, model.Remark, olt?.Version, Environment.MachineName, ip);
    //        hi.Client = clientId;
    //        if (time.Year > 2000) hi.CreateTime = time;
    //        his.Add(hi);
    //    }

    //    his.Insert();

    //    return events.Length;
    //}

    protected override IEntity CreateEvent(DeviceContext context, IDeviceModel2 device, EventModel model)
    {
        var entity = base.CreateEvent(context, device, model);
        if (entity is AppHistory history)
        {
            var online = GetOnline(context) as AppOnline;
            history.Version = online?.Version;
            history.Client = online?.Client;
            history.NodeId = online?.NodeId ?? 0;

            var time = model.Time.ToDateTime().ToLocalTime();
            if (time.Year > 2000) history.CreateTime = time;
        }

        return entity;
    }
    #endregion

    #region 辅助
    public override IDeviceModel? QueryDevice(String code) => App.FindByName(code);

    public override IOnlineModel? QueryOnline(String sessionId) => AppOnline.FindBySessionId(sessionId, true);

    protected override String GetSessionId(DeviceContext context) => context.ClientId ?? base.GetSessionId(context);

    private void WriteHistory(App app, String action, Boolean success, String remark, String ip, String clientId)
    {
        var online = AppOnline.FindBySessionId(clientId, true);
        var version = online?.Version;

        var history = AppHistory.Create(app, action, success, remark, version, Environment.MachineName, ip);
        history.Client = clientId;
        history.NodeId = online?.NodeId ?? 0;
        history.Insert();
    }

    /// <summary>写设备历史</summary>
    /// <param name="context">上下文</param>
    /// <param name="action">动作</param>
    /// <param name="success">成功</param>
    /// <param name="remark">备注内容</param>
    public override void WriteHistory(DeviceContext context, String action, Boolean success, String remark)
    {
        var online = context.Online as AppOnline;
        var version = online?.Version;
        var history = AppHistory.Create(context.Device as App, action, success, remark, version, Environment.MachineName, context.UserHost);
        history.Client = context.ClientId;
        history.NodeId = online?.NodeId ?? 0;
        history.Insert();
    }
    #endregion
}