Upgrade Nuget
大石头 authored at 2024-09-08 14:02:44
12.62 KiB
NewLife.Remoting
using System.Reflection;
using NewLife;
using NewLife.Caching;
using NewLife.Caching.Queues;
using NewLife.Log;
using NewLife.Remoting;
using NewLife.Remoting.Extensions.Services;
using NewLife.Remoting.Models;
using NewLife.Security;
using NewLife.Serialization;
using NewLife.Web;
using XCode.Membership;
using Zero.Data.Nodes;
using ZeroServer.Models;

namespace ZeroServer.Services;

/// <summary>设备服务</summary>
public class NodeService : IDeviceService
{
    private readonly ICacheProvider _cacheProvider;
    private readonly ICache _cache;
    private readonly IPasswordProvider _passwordProvider;
    private readonly IoTSetting _setting;
    private readonly ITracer _tracer;

    /// <summary>
    /// 实例化设备服务
    /// </summary>
    /// <param name="passwordProvider"></param>
    /// <param name="dataService"></param>
    /// <param name="cacheProvider"></param>
    /// <param name="setting"></param>
    /// <param name="tracer"></param>
    public NodeService(IPasswordProvider passwordProvider, ICacheProvider cacheProvider, IoTSetting setting, ITracer tracer)
    {
        _passwordProvider = passwordProvider;
        _cacheProvider = cacheProvider;
        _cache = cacheProvider.InnerCache;
        _setting = setting;
        _tracer = tracer;
    }

    #region 登录注销
    /// <summary>
    /// 设备登录验证,内部支持动态注册
    /// </summary>
    /// <param name="request">登录信息</param>
    /// <param name="source">登录来源</param>
    /// <param name="ip">远程IP</param>
    /// <returns></returns>
    /// <exception cref="ApiException"></exception>
    public (IDeviceModel, IOnlineModel, ILoginResponse) Login(ILoginRequest request, String source, String ip)
    {
        if (request is not LoginInfo inf) throw new ArgumentOutOfRangeException(nameof(request));

        var code = inf.Code;
        var secret = inf.Secret;

        var node = Node.FindByCode(code!, true);
        if (node != null && !node.Enable) throw new ApiException(99, "禁止登录");

        var autoReg = false;
        if (node == null)
        {
            node = AutoRegister(null, inf, ip);
            autoReg = true;
        }
        else
        {
            if (!node.Enable) throw new ApiException(ApiCode.Forbidden, "禁止登录");

            // 校验唯一编码,防止客户端拷贝配置
            var uuid = inf.UUID;
            if (!uuid.IsNullOrEmpty() && !node.Uuid.IsNullOrEmpty() && uuid != node.Uuid)
                WriteHistory(node, source + "登录校验", false, $"新旧唯一标识不一致!(新){uuid}!={node.Uuid}(旧)", ip);

            // 登录密码未设置或者未提交,则执行动态注册
            if (node == null || !node.Secret.IsNullOrEmpty()
                && (secret.IsNullOrEmpty() || !_passwordProvider.Verify(node.Secret, secret)))
            {
                node = AutoRegister(node, inf, ip);
                autoReg = true;
            }
        }

        if (node == null) throw new ApiException(ApiCode.Unauthorized, "节点鉴权失败");

        node.Login(inf, ip);

        // 在线记录
        var olt = GetOnline(node, ip) ?? CreateOnline(node, ip);
        olt.Save(inf, null, null, ip);

        // 登录历史
        WriteHistory(node, source + "节点鉴权", true, $"[{node.Name}/{node.Code}]鉴权成功 " + inf.ToJson(false, false, false), ip);

        var rs = new LoginResponse
        {
            Name = node.Name
        };

        // 动态注册,下发节点证书
        if (autoReg) rs.Secret = node.Secret;

        return (node, olt, rs);
    }

    /// <summary>自动注册</summary>
    /// <param name="node"></param>
    /// <param name="inf"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    /// <exception cref="ApiException"></exception>
    public Node AutoRegister(Node? node, LoginInfo inf, String ip)
    {
        // 全局开关,是否允许自动注册新产品
        if (!_setting.AutoRegister) throw new ApiException(12, "禁止自动注册");

        var code = inf.Code;
        if (code.IsNullOrEmpty()) code = inf.UUID.GetBytes().Crc().ToString("X8");
        if (code.IsNullOrEmpty()) code = Rand.NextString(8);

        node ??= Node.FindByCode(code, false);
        node ??= new Node
        {
            Code = code,
            CreateIP = ip,
            CreateTime = DateTime.Now,
            Secret = Rand.NextString(8),
        };

        // 如果未打开动态注册,则把节点修改为禁用
        node.Enable = true;

        if (node.Name.IsNullOrEmpty()) node.Name = inf.Name;

        node.ProductCode = inf.ProductCode;
        node.Secret = Rand.NextString(16);
        node.UpdateIP = ip;
        node.UpdateTime = DateTime.Now;

        node.Save();

        WriteHistory(node, "动态注册", true, inf.ToJson(false, false, false), ip);

        return node;
    }

    /// <summary>注销</summary>
    /// <param name="model">设备</param>
    /// <param name="reason">注销原因</param>
    /// <param name="source">登录来源</param>
    /// <param name="ip">远程IP</param>
    /// <returns></returns>
    public IOnlineModel Logout(IDeviceModel model, String reason, String source, String ip)
    {
        var node = model as Node;
        var online = GetOnline(node, ip);
        if (online != null)
        {
            var msg = $"{reason} [{model}]]登录于{online.CreateTime.ToFullString()},最后活跃于{online.UpdateTime.ToFullString()}";
            WriteHistory(model, source + "设备下线", true, msg, ip);
            online.Delete();

            var sid = $"{node.Id}@{ip}";
            _cache.Remove($"NodeOnline:{sid}");

            // 计算在线时长
            if (online.CreateTime.Year > 2000)
            {
                node.OnlineTime += (Int32)(DateTime.Now - online.CreateTime).TotalSeconds;
                node.Update();
            }
        }

        return online;
    }
    #endregion

    #region 心跳保活
    /// <summary>心跳</summary>
    /// <param name="inf"></param>
    /// <param name="token"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    public IOnlineModel Ping(IDeviceModel model, IPingRequest request, String token, String ip)
    {
        var node = model as Node;
        var inf = request as PingInfo;
        if (inf != null && !inf.IP.IsNullOrEmpty()) node.IP = inf.IP;

        node.UpdateIP = ip;
        node.FixArea();

        // 每10分钟更新一次节点信息,确保活跃
        if (node.LastActive.AddMinutes(10) < DateTime.Now) node.LastActive = DateTime.Now;
        node.SaveAsync();

        var online = GetOnline(node, ip) ?? CreateOnline(node, ip);
        online.Name = model.Name;
        online.Category = node.Category;
        online.Version = node.Version;
        online.CompileTime = node.CompileTime;
        online.OSKind = node.OSKind;
        online.Save(null, inf, token, ip);

        return online;
    }

    /// <summary>设置设备的长连接上线/下线</summary>
    /// <param name="device"></param>
    /// <param name="online"></param>
    /// <param name="token"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    public IOnlineModel SetOnline(IDeviceModel device, Boolean online, String token, String ip)
    {
        if (device is Node node)
        {
            // 上线打标记
            var olt = GetOnline(node, ip);
            if (olt != null)
            {
                olt.WebSocket = online;
                olt.Update();
            }

            return olt;
        }

        return null;
    }

    /// <summary></summary>
    /// <param name="node"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    public virtual NodeOnline GetOnline(Node node, String ip)
    {
        var sid = $"{node.Id}@{ip}";
        var online = _cache.Get<NodeOnline>($"NodeOnline:{sid}");
        if (online != null)
        {
            _cache.SetExpire($"NodeOnline:{sid}", TimeSpan.FromSeconds(600));
            return online;
        }

        return NodeOnline.FindBySessionID(sid);
    }

    /// <summary>检查在线</summary>
    /// <param name="node"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    public virtual NodeOnline CreateOnline(Node node, String ip)
    {
        var sid = $"{node.Id}@{ip}";
        var online = NodeOnline.GetOrAdd(sid);
        online.NodeId = node.Id;
        online.Name = node.Name;
        online.IP = node.IP;
        online.CreateIP = ip;

        online.Creator = Environment.MachineName;

        _cache.Set($"NodeOnline:{sid}", online, 600);

        return online;
    }

    /// <summary>删除在线</summary>
    /// <param name="deviceId"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    public Int32 RemoveOnline(Int32 deviceId, String ip)
    {
        var sid = $"{deviceId}@{ip}";

        return _cache.Remove($"NodeOnline:{sid}");
    }
    #endregion

    #region 下行通知
    /// <summary>
    /// 获取指定设备的命令队列
    /// </summary>
    /// <param name="deviceCode"></param>
    /// <returns></returns>
    public IProducerConsumer<String> GetQueue(String deviceCode)
    {
        var q = _cacheProvider.GetQueue<String>($"cmd:{deviceCode}");
        if (q is QueueBase qb) qb.TraceName = "ServiceQueue";

        return q;
    }
    #endregion

    #region 升级更新
    /// <summary>升级检查</summary>
    /// <param name="channel">更新通道</param>
    /// <returns></returns>
    public IUpgradeInfo Upgrade(IDeviceModel device, String channel, String ip)
    {
        //return new UpgradeInfo();
        return null;
    }
    #endregion

    #region 事件上报
    /// <summary>命令响应</summary>
    /// <param name="device"></param>
    /// <param name="model"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    public Int32 CommandReply(IDeviceModel device, CommandReplyModel model, String ip) => 0;

    /// <summary>上报事件</summary>
    /// <param name="device"></param>
    /// <param name="events"></param>
    /// <param name="ip"></param>
    /// <returns></returns>
    public Int32 PostEvents(IDeviceModel device, EventModel[] events, String ip) => 0;
    #endregion

    #region 辅助
    /// <summary>
    /// 颁发令牌
    /// </summary>
    /// <param name="name"></param>
    /// <param name="set"></param>
    /// <returns></returns>
    public TokenModel IssueToken(String name, IoTSetting set)
    {
        // 颁发令牌
        var ss = set.TokenSecret.Split(':');
        var jwt = new JwtBuilder
        {
            Issuer = Assembly.GetEntryAssembly().GetName().Name,
            Subject = name,
            Id = Rand.NextString(8),
            Expire = DateTime.Now.AddSeconds(set.TokenExpire),

            Algorithm = ss[0],
            Secret = ss[1],
        };

        return new TokenModel
        {
            AccessToken = jwt.Encode(null),
            TokenType = jwt.Type ?? "JWT",
            ExpireIn = set.TokenExpire,
            RefreshToken = jwt.Encode(null),
        };
    }

    /// <summary>
    /// 验证并颁发令牌
    /// </summary>
    /// <param name="deviceCode"></param>
    /// <param name="token"></param>
    /// <returns></returns>
    public TokenModel ValidAndIssueToken(String deviceCode, String token)
    {
        if (token.IsNullOrEmpty()) return null;

        // 令牌有效期检查,10分钟内过期者,重新颁发令牌
        var ss = _setting.TokenSecret.Split(':');
        var jwt = new JwtBuilder
        {
            Algorithm = ss[0],
            Secret = ss[1],
        };
        var rs = jwt.TryDecode(token, out var message);
        if (!rs || jwt == null) return null;

        if (DateTime.Now.AddMinutes(10) > jwt.Expire) return IssueToken(deviceCode, _setting);

        return null;
    }

    /// <summary>查找设备</summary>
    /// <param name="code"></param>
    /// <returns></returns>
    public IDeviceModel QueryDevice(String code) => Node.FindByCode(code);

    /// <summary>
    /// 写设备历史
    /// </summary>
    /// <param name="model"></param>
    /// <param name="action"></param>
    /// <param name="success"></param>
    /// <param name="remark"></param>
    /// <param name="ip"></param>
    public void WriteHistory(IDeviceModel model, String action, Boolean success, String remark, String ip)
    {
        var history = NodeHistory.Create(model as Node, action, success, remark, Environment.MachineName, ip);

        if (history.CityID == 0 && !ip.IsNullOrEmpty())
        {
            var rs = Area.SearchIP(ip);
            if (rs.Count > 0) history.ProvinceID = rs[0].ID;
            if (rs.Count > 1) history.CityID = rs[^1].ID;
        }

        history.SaveAsync();
    }
    #endregion
}