From 3927b0c201deaf4a0eb235a3635fb2dc188e9fc5 Mon Sep 17 00:00:00 2001 From: wuyi Date: Thu, 3 Sep 2026 14:02:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=90=AD=E5=BB=BA=E6=B6=88=E6=81=AF=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E6=A8=A1=E5=9D=97=E4=BB=A5=E5=8F=8A=E8=B7=A8=E7=BD=91?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=EF=BC=8C=E9=A3=9E=E4=B9=A6=EF=BC=8C=E9=92=89?= =?UTF-8?q?=E9=92=89=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AlertBridge/AlertBridge.csproj | 21 + AlertBridge/Program.cs | 259 ++++++++ AlertBridge/appsettings.json | 5 + Common/Notify/FeishuConfig.cs | 26 + Common/Notify/ImWebhookSigner.cs | 40 ++ Common/Notify/NotifyError.cs | 83 +++ IOT_API.sln | 74 +++ .../Inspection/AlertMessageController.cs | 146 ++++- .../Inspection/AlertNotifyController.cs | 196 ++++++ IOT_API/DependencyInjection.cs | 37 ++ IOT_API/Program.cs | 8 + IOT_API/Services/AlertNotifyWorker.cs | 230 +++++++ IOT_API/Services/TemperatureBoxSimulator.cs | 35 +- Model/Dto/Inspection/AlertMessageDto.cs | 82 +++ Model/Dto/Inspection/AlertNotifyDtos.cs | 136 ++++ Model/Dto/Inspection/AlertOutboxDtos.cs | 59 ++ Model/Dto/Inspection/AlertRaiseDto.cs | 62 ++ Model/Dto/Inspection/AlertStatisticsDtos.cs | 101 +++ Model/Entity/Inspection/AlertEnums.cs | 221 +++++++ Model/Entity/Inspection/AlertMessageEntity.cs | 151 +++++ .../Inspection/AlertNotifyChannelEntity.cs | 52 ++ .../Entity/Inspection/AlertNotifyLogEntity.cs | 65 ++ .../Inspection/AlertNotifyRuleEntity.cs | 58 ++ Model/Entity/Inspection/AlertOutboxEntity.cs | 61 ++ Model/Entity/Inspection/DutyRosterEntity.cs | 35 ++ Model/Mapper/EntityMapper.cs | 270 ++++++++ .../Inspection/AlertCenterService.cs | 115 ++++ .../Inspection/AlertMessageService.cs | 404 +++++++++++- .../Implement/Inspection/AlertNotifyBus.cs | 39 ++ .../Inspection/AlertNotifyService.cs | 591 ++++++++++++++++++ .../Inspection/Notify/DingTalkNotifier.cs | 95 +++ .../Inspection/Notify/FeishuContactService.cs | 147 +++++ .../Inspection/Notify/FeishuNotifier.cs | 113 ++++ .../Inspection/Notify/WeComNotifier.cs | 75 +++ .../Inspection/IAlertCenterService.cs | 21 + .../Inspection/IAlertMessageService.cs | 73 ++- .../Interface/Inspection/IAlertNotifier.cs | 67 ++ .../Inspection/IAlertNotifyService.cs | 95 +++ .../Inspection/IFeishuContactService.cs | 17 + Service/Service.csproj | 2 + 40 files changed, 4360 insertions(+), 7 deletions(-) create mode 100644 AlertBridge/AlertBridge.csproj create mode 100644 AlertBridge/Program.cs create mode 100644 AlertBridge/appsettings.json create mode 100644 Common/Notify/FeishuConfig.cs create mode 100644 Common/Notify/ImWebhookSigner.cs create mode 100644 Common/Notify/NotifyError.cs create mode 100644 IOT_API/Controllers/Inspection/AlertNotifyController.cs create mode 100644 IOT_API/Services/AlertNotifyWorker.cs create mode 100644 Model/Dto/Inspection/AlertMessageDto.cs create mode 100644 Model/Dto/Inspection/AlertNotifyDtos.cs create mode 100644 Model/Dto/Inspection/AlertOutboxDtos.cs create mode 100644 Model/Dto/Inspection/AlertRaiseDto.cs create mode 100644 Model/Dto/Inspection/AlertStatisticsDtos.cs create mode 100644 Model/Entity/Inspection/AlertEnums.cs create mode 100644 Model/Entity/Inspection/AlertMessageEntity.cs create mode 100644 Model/Entity/Inspection/AlertNotifyChannelEntity.cs create mode 100644 Model/Entity/Inspection/AlertNotifyLogEntity.cs create mode 100644 Model/Entity/Inspection/AlertNotifyRuleEntity.cs create mode 100644 Model/Entity/Inspection/AlertOutboxEntity.cs create mode 100644 Model/Entity/Inspection/DutyRosterEntity.cs create mode 100644 Service/Implement/Inspection/AlertCenterService.cs create mode 100644 Service/Implement/Inspection/AlertNotifyBus.cs create mode 100644 Service/Implement/Inspection/AlertNotifyService.cs create mode 100644 Service/Implement/Inspection/Notify/DingTalkNotifier.cs create mode 100644 Service/Implement/Inspection/Notify/FeishuContactService.cs create mode 100644 Service/Implement/Inspection/Notify/FeishuNotifier.cs create mode 100644 Service/Implement/Inspection/Notify/WeComNotifier.cs create mode 100644 Service/Interface/Inspection/IAlertCenterService.cs create mode 100644 Service/Interface/Inspection/IAlertNotifier.cs create mode 100644 Service/Interface/Inspection/IAlertNotifyService.cs create mode 100644 Service/Interface/Inspection/IFeishuContactService.cs diff --git a/AlertBridge/AlertBridge.csproj b/AlertBridge/AlertBridge.csproj new file mode 100644 index 0000000..7112ef8 --- /dev/null +++ b/AlertBridge/AlertBridge.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + PreserveNewest + + + + diff --git a/AlertBridge/Program.cs b/AlertBridge/Program.cs new file mode 100644 index 0000000..cce42cb --- /dev/null +++ b/AlertBridge/Program.cs @@ -0,0 +1,259 @@ +using Common.Notify; +using Model.Dto.Inspection; +using System.Text; +using System.Text.Json; + +namespace AlertBridge +{ + /// + /// 跨网告警转发程序(部署在可同时访问"工控内网 IOT_API"与"外网 IM Webhook"的跳板机上) + /// 工作方式:轮询拉取内网发件箱(GET outbox/pull)→ 按报文推送飞书/钉钉/企微 → 回写结果(POST outbox/{id}/result) + /// 报文自包含 WebhookUrl/Secret/内容,本程序无需访问数据库,配置仅需 appsettings.json 三项 + /// + public class Program + { + private static string _apiBaseUrl = "http://localhost:5287"; + private static int _pollSeconds = 5; + private static int _batchSize = 20; + + public static async Task Main(string[] args) + { + LoadConfig(); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + Log($"AlertBridge 启动,API 地址: {_apiBaseUrl},轮询间隔: {_pollSeconds}s,批量: {_batchSize}"); + Log("按 Ctrl+C 停止"); + + while (!cts.IsCancellationRequested) + { + try + { + await PollOnceAsync(http, cts.Token); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Log($"轮询异常: {ex.Message}"); + } + + try { await Task.Delay(TimeSpan.FromSeconds(_pollSeconds), cts.Token); } + catch (TaskCanceledException) { break; } + } + Log("AlertBridge 已停止"); + } + + /// + /// 单次轮询:拉取 → 逐条推送 → 回写 + /// + private static async Task PollOnceAsync(HttpClient http, CancellationToken ct) + { + string pullUrl = $"{_apiBaseUrl}/api/inspection/alert-notify/outbox/pull?batch={_batchSize}"; + string pullJson = await http.GetStringAsync(pullUrl, ct); + + var pullResult = JsonSerializer.Deserialize>>(pullJson, JsonOpts); + if (pullResult == null || pullResult.Code != 0 || pullResult.Data == null || pullResult.Data.Count == 0) + { + return; + } + + Log($"拉取到 {pullResult.Data.Count} 条待推送消息"); + foreach (var item in pullResult.Data) + { + bool success = false; + string? error = null; + try + { + var payload = JsonSerializer.Deserialize(item.Payload ?? "{}", JsonOpts); + if (payload == null || string.IsNullOrWhiteSpace(payload.WebhookUrl)) + { + error = "Payload 为空或缺少 WebhookUrl"; + } + else + { + (success, error) = await SendAsync(http, payload, ct); + } + } + catch (Exception ex) + { + error = NotifyError.Describe(ex); + } + + // 回写结果 + try + { + var resultBody = JsonSerializer.Serialize(new { Success = success, ResultMsg = error }, JsonOpts); + using var content = new StringContent(resultBody, Encoding.UTF8, "application/json"); + await http.PostAsync($"{_apiBaseUrl}/api/inspection/alert-notify/outbox/{item.Id}/result", content, ct); + Log($"消息 {item.Id} 推送{(success ? "成功" : $"失败: {error}")},结果已回写"); + } + catch (Exception ex) + { + Log($"消息 {item.Id} 结果回写失败: {ex.Message}"); + } + } + } + + /// + /// 按渠道类型推送(与主站 Notifier 报文格式保持一致) + /// + private static async Task<(bool success, string? error)> SendAsync(HttpClient http, AlertOutboxPayload p, CancellationToken ct) + { + string url = p.WebhookUrl!; + object body; + + switch (p.ChannelType) + { + case 1: // 飞书:post 富文本 + at 标签(user_id=open_id) 真正 @ + 可选加签(timestamp 秒 + sign) + { + var contentLines = new List>>(); + foreach (var raw in (p.Content ?? string.Empty).Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.Length == 0) continue; + contentLines.Add(new List> + { + new Dictionary { ["tag"] = "text", ["text"] = line } + }); + } + if (p.AtMobiles is { Count: > 0 }) + { + var atLine = new List> + { + new Dictionary { ["tag"] = "text", ["text"] = "关注人: " } + }; + foreach (var openId in p.AtMobiles) + { + if (string.IsNullOrWhiteSpace(openId)) continue; + atLine.Add(new Dictionary { ["tag"] = "at", ["user_id"] = openId.Trim() }); + } + contentLines.Add(atLine); + } + + var feishuBody = new Dictionary + { + ["msg_type"] = "post", + ["content"] = new Dictionary + { + ["post"] = new Dictionary + { + ["zh_cn"] = new Dictionary + { + ["title"] = p.Title ?? "设备告警", + ["content"] = contentLines + } + } + } + }; + if (!string.IsNullOrWhiteSpace(p.Secret)) + { + long ts = DateTimeOffset.Now.ToUnixTimeSeconds(); + feishuBody["timestamp"] = ts.ToString(); + feishuBody["sign"] = ImWebhookSigner.FeishuSign(ts, p.Secret); + } + body = feishuBody; + break; + } + case 2: // 钉钉:markdown + at.atMobiles + URL 加签(timestamp 毫秒 + sign) + { + if (!string.IsNullOrWhiteSpace(p.Secret)) + { + long ts = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + url += $"{(url.Contains('?') ? "&" : "?")}timestamp={ts}&sign={ImWebhookSigner.DingTalkSign(ts, p.Secret)}"; + } + var md = new StringBuilder(); + md.AppendLine($"### {p.Title}"); + md.AppendLine(p.Content); + foreach (var m in p.AtMobiles ?? new List()) md.Append($"@{m} "); + + body = new Dictionary + { + ["msgtype"] = "markdown", + ["markdown"] = new Dictionary { ["title"] = p.Title ?? "设备告警", ["text"] = md.ToString() }, + ["at"] = new Dictionary { ["atMobiles"] = p.AtMobiles ?? new List(), ["isAtAll"] = false } + }; + break; + } + case 3: // 企微:text + mentioned_mobile_list(无加签) + { + var text = new StringBuilder(); + text.AppendLine(p.Title); + text.AppendLine(p.Content); + body = new Dictionary + { + ["msgtype"] = "text", + ["text"] = new Dictionary + { + ["content"] = text.ToString(), + ["mentioned_mobile_list"] = p.AtMobiles ?? new List() + } + }; + break; + } + default: + return (false, $"未知渠道类型: {p.ChannelType}"); + } + + string json = JsonSerializer.Serialize(body, JsonOpts); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await http.PostAsync(url, content, ct); + string respText = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + return (false, $"HTTP {(int)response.StatusCode}: {Truncate(respText)}"); + } + + // 飞书返回 code/StatusCode,钉钉/企微返回 errcode,0 均为成功 + using var doc = JsonDocument.Parse(respText); + var root = doc.RootElement; + bool ok = (root.TryGetProperty("errcode", out var ec) && ec.GetInt32() == 0) + || (root.TryGetProperty("code", out var c) && c.GetInt32() == 0) + || (root.TryGetProperty("StatusCode", out var sc) && sc.GetInt32() == 0); + return ok ? (true, null) : (false, Truncate(respText)); + } + + /// + /// 读取 appsettings.json(免依赖 Microsoft.Extensions.Configuration,直接 JSON 解析) + /// + private static void LoadConfig() + { + try + { + string path = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(path)) return; + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var root = doc.RootElement; + if (root.TryGetProperty("ApiBaseUrl", out var u)) _apiBaseUrl = u.GetString() ?? _apiBaseUrl; + if (root.TryGetProperty("PollSeconds", out var p)) _pollSeconds = p.GetInt32(); + if (root.TryGetProperty("BatchSize", out var b)) _batchSize = b.GetInt32(); + } + catch (Exception ex) + { + Log($"读取配置失败,使用默认值: {ex.Message}"); + } + } + + private static string Truncate(string s, int max = 200) => + string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "..."; + + private static void Log(string msg) => Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}"); + + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; + + /// + /// 主站统一返回结构(仅取所需字段) + /// + private class ResultDto + { + public int Code { get; set; } + public string? Msg { get; set; } + public T? Data { get; set; } + } + } +} diff --git a/AlertBridge/appsettings.json b/AlertBridge/appsettings.json new file mode 100644 index 0000000..2254800 --- /dev/null +++ b/AlertBridge/appsettings.json @@ -0,0 +1,5 @@ +{ + "ApiBaseUrl": "http://localhost:5287", + "PollSeconds": 5, + "BatchSize": 20 +} diff --git a/Common/Notify/FeishuConfig.cs b/Common/Notify/FeishuConfig.cs new file mode 100644 index 0000000..4bd4d63 --- /dev/null +++ b/Common/Notify/FeishuConfig.cs @@ -0,0 +1,26 @@ +namespace Common.Notify +{ + /// + /// 飞书自建应用配置(用于"手机号 → open_id"转换) + /// 飞书群机器人 webhook 只能发文本、无法用手机号 @ 人,必须借助自建应用凭证调通讯录接口换取 open_id。 + /// 由 Program.cs 启动时从 appsettings.json 的 Feishu 节读入;未配置时相关功能自动降级。 + /// + public static class FeishuConfig + { + /// 飞书自建应用 App ID(cli_ 开头) + public static string AppId { get; private set; } = string.Empty; + + /// 飞书自建应用 App Secret + public static string AppSecret { get; private set; } = string.Empty; + + /// 是否已配置(未配置时手机号无法转 open_id,降级为仅支持直接填 open_id / all) + public static bool IsConfigured => !string.IsNullOrWhiteSpace(AppId) && !string.IsNullOrWhiteSpace(AppSecret); + + /// 初始化飞书应用配置 + public static void Init(string appId, string appSecret) + { + AppId = appId ?? string.Empty; + AppSecret = appSecret ?? string.Empty; + } + } +} diff --git a/Common/Notify/ImWebhookSigner.cs b/Common/Notify/ImWebhookSigner.cs new file mode 100644 index 0000000..7402f1e --- /dev/null +++ b/Common/Notify/ImWebhookSigner.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Common.Notify +{ + /// + /// IM 机器人 Webhook 加签工具(钉钉/飞书) + /// 供 IOT_API 直连推送与 AlertBridge 跨网转发程序共用,保证两种模式签名算法一致 + /// + public static class ImWebhookSigner + { + /// + /// 钉钉机器人加签:sign = UrlEncode(Base64(HmacSHA256(timestamp + "\n" + secret, key=secret))) + /// + /// 毫秒时间戳 + /// 加签密钥(SEC 开头) + /// URL 编码后的签名 + public static string DingTalkSign(long timestampMs, string secret) + { + string stringToSign = $"{timestampMs}\n{secret}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + byte[] signData = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign)); + return Uri.EscapeDataString(Convert.ToBase64String(signData)); + } + + /// + /// 飞书机器人加签:sign = Base64(HmacSHA256(空串, key=timestamp + "\n" + secret)) + /// 注意与钉钉相反:飞书以"时间戳\n密钥"为 key、对空内容签名 + /// + /// 秒级时间戳 + /// 加签密钥 + /// Base64 签名 + public static string FeishuSign(long timestampSec, string secret) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes($"{timestampSec}\n{secret}")); + byte[] signData = hmac.ComputeHash(Array.Empty()); + return Convert.ToBase64String(signData); + } + } +} diff --git a/Common/Notify/NotifyError.cs b/Common/Notify/NotifyError.cs new file mode 100644 index 0000000..5e2ac1a --- /dev/null +++ b/Common/Notify/NotifyError.cs @@ -0,0 +1,83 @@ +using System.Security.Authentication; +using System.Text; + +namespace Common.Notify +{ + /// + /// 通知推送异常诊断工具 + /// HttpClient 网络异常(尤其 SSL/TLS 握手失败)的真实根因藏在 InnerException 链里, + /// 只记录外层 ex.Message 会得到"see inner exception"这类无用信息。 + /// 本工具展开整条异常链并针对常见网络故障给出可读排查提示,供三个 Notifier 复用。 + /// + public static class NotifyError + { + /// + /// 将异常展开为"外层 -> 内层 -> ..."的可读描述,并附加常见网络故障排查提示 + /// + public static string Describe(Exception? ex) + { + if (ex == null) return "未知错误"; + + var sb = new StringBuilder(); + var current = ex; + int depth = 0; + // 最多展开 5 层,防止极端嵌套导致信息过长 + while (current != null && depth < 5) + { + if (depth > 0) sb.Append(" -> "); + sb.Append(current.Message); + current = current.InnerException; + depth++; + } + + if (IsTimeout(ex)) + { + sb.Append("|排查:请求超时,请确认目标地址可达、Webhook 域名解析正常、无防火墙拦截出站 443"); + } + else if (IsSslFailure(ex)) + { + sb.Append("|排查:SSL/TLS 握手失败,常见原因为 " + + "①服务器无法访问外网(工控内网请改用\"跨网中转(Outbox)\"推送模式,由跳板机 AlertBridge 实际发送) " + + "②企业代理/防火墙做 SSL 拦截且其根证书未被本机信任 " + + "③系统时间不准导致证书校验失败 " + + "④Webhook 地址主机名或端口有误(非标准 HTTPS 端口)"); + } + + return sb.ToString(); + } + + /// + /// 是否为超时(TaskCanceledException 且非用户主动取消,或 TimeoutException) + /// + private static bool IsTimeout(Exception ex) + { + for (var c = ex; c != null; c = c.InnerException) + { + if (c is TimeoutException) return true; + // HttpClient 超时表现为 TaskCanceledException,内部通常带 TimeoutException + if (c is TaskCanceledException && c.InnerException is TimeoutException) return true; + } + return false; + } + + /// + /// 是否为 SSL/TLS 握手或证书校验失败 + /// + private static bool IsSslFailure(Exception ex) + { + for (var c = ex; c != null; c = c.InnerException) + { + if (c is AuthenticationException) return true; + var msg = c.Message; + if (!string.IsNullOrEmpty(msg) && + (msg.Contains("SSL", StringComparison.OrdinalIgnoreCase) || + msg.Contains("TLS", StringComparison.OrdinalIgnoreCase) || + msg.Contains("certificate", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + return false; + } + } +} diff --git a/IOT_API.sln b/IOT_API.sln index 02cf8f5..2bf0d84 100644 --- a/IOT_API.sln +++ b/IOT_API.sln @@ -17,40 +17,114 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeviceCommand", "DeviceComm EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IOT_API", "IOT_API\IOT_API.csproj", "{1E2A7C73-2CAE-4084-939B-0F8EE73AA955}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AlertBridge", "AlertBridge\AlertBridge.csproj", "{C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x64.ActiveCfg = Debug|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x64.Build.0 = Debug|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x86.ActiveCfg = Debug|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Debug|x86.Build.0 = Debug|Any CPU {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|Any CPU.Build.0 = Release|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x64.ActiveCfg = Release|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x64.Build.0 = Release|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x86.ActiveCfg = Release|Any CPU + {6D9764D9-B4DA-43E2-A9D7-40A6C871A6B3}.Release|x86.Build.0 = Release|Any CPU {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x64.ActiveCfg = Debug|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x64.Build.0 = Debug|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x86.ActiveCfg = Debug|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Debug|x86.Build.0 = Debug|Any CPU {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|Any CPU.ActiveCfg = Release|Any CPU {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|Any CPU.Build.0 = Release|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x64.ActiveCfg = Release|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x64.Build.0 = Release|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x86.ActiveCfg = Release|Any CPU + {9150C6A9-AE8D-42C9-8B2D-9DD04A3E7E74}.Release|x86.Build.0 = Release|Any CPU {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x64.Build.0 = Debug|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Debug|x86.Build.0 = Debug|Any CPU {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|Any CPU.ActiveCfg = Release|Any CPU {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|Any CPU.Build.0 = Release|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x64.ActiveCfg = Release|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x64.Build.0 = Release|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x86.ActiveCfg = Release|Any CPU + {4DE5DC6C-7121-4EB9-B8A8-90C694F451E2}.Release|x86.Build.0 = Release|Any CPU {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x64.Build.0 = Debug|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Debug|x86.Build.0 = Debug|Any CPU {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|Any CPU.ActiveCfg = Release|Any CPU {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|Any CPU.Build.0 = Release|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x64.ActiveCfg = Release|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x64.Build.0 = Release|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x86.ActiveCfg = Release|Any CPU + {D8209B91-D7D0-444B-B569-D3FA74D191DD}.Release|x86.Build.0 = Release|Any CPU {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x64.Build.0 = Debug|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Debug|x86.Build.0 = Debug|Any CPU {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|Any CPU.ActiveCfg = Release|Any CPU {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|Any CPU.Build.0 = Release|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x64.ActiveCfg = Release|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x64.Build.0 = Release|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x86.ActiveCfg = Release|Any CPU + {C769E6C6-55E9-40C3-A611-9EFAB101BE6A}.Release|x86.Build.0 = Release|Any CPU {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x64.Build.0 = Debug|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Debug|x86.Build.0 = Debug|Any CPU {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|Any CPU.Build.0 = Release|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x64.ActiveCfg = Release|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x64.Build.0 = Release|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x86.ActiveCfg = Release|Any CPU + {2F035F70-5F1D-4C22-B4F0-1AEA0ED127A6}.Release|x86.Build.0 = Release|Any CPU {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x64.Build.0 = Debug|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Debug|x86.Build.0 = Debug|Any CPU {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|Any CPU.Build.0 = Release|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x64.ActiveCfg = Release|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x64.Build.0 = Release|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x86.ActiveCfg = Release|Any CPU + {1E2A7C73-2CAE-4084-939B-0F8EE73AA955}.Release|x86.Build.0 = Release|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x64.Build.0 = Debug|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Debug|x86.Build.0 = Debug|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|Any CPU.Build.0 = Release|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x64.ActiveCfg = Release|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x64.Build.0 = Release|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x86.ActiveCfg = Release|Any CPU + {C764BDBA-3D96-4BA1-9AA5-D9D284E7C6AA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/IOT_API/Controllers/Inspection/AlertMessageController.cs b/IOT_API/Controllers/Inspection/AlertMessageController.cs index 69feae9..c87abed 100644 --- a/IOT_API/Controllers/Inspection/AlertMessageController.cs +++ b/IOT_API/Controllers/Inspection/AlertMessageController.cs @@ -1,14 +1,156 @@ using Microsoft.AspNetCore.Mvc; +using Model; +using Model.Dto.Inspection; +using Model.Entity.Inspection; +using Service.Interface; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; namespace WebAPI.Controllers { /// - /// 消息告警 + /// 消息告警(告警列表与确认 / 告警统计分析 / 统一告警上报入口) /// [ApiController] [Route("api/inspection/alert-message")] public class AlertMessageController : ControllerBase { - // TODO: 实现 消息告警 相关接口 + private readonly IAlertMessageService _alertMessageService; + private readonly IAlertCenterService _alertCenterService; + + public AlertMessageController(IAlertMessageService alertMessageService, IAlertCenterService alertCenterService) + { + _alertMessageService = alertMessageService; + _alertCenterService = alertCenterService; + } + + /// + /// 告警列表(分页,支持级别/状态/类型/来源/设备/关键字/时间范围筛选;总记录数见响应头 X-Total-Count) + /// + [HttpGet("list")] + public async Task>> GetList(int pageIndex = 1, int pageSize = 10, + AlertLevelEnum? level = null, AlertStatusEnum? status = null, AlertTypeEnum? type = null, + AlertSourceEnum? source = null, string? deviceCode = null, string? keyword = null, + DateTime? startDate = null, DateTime? endDate = null) + { + RefAsync total = 0; + var result = await _alertMessageService.GetPagedAsync(pageIndex, pageSize, total, + level, status, type, source, deviceCode, keyword, startDate, endDate); + Response.Headers["X-Total-Count"] = total.Value.ToString(); + return result; + } + + /// + /// 告警详情 + /// + [HttpGet("{id}")] + public async Task> GetById(long id) + { + return await _alertMessageService.GetByIdAsync(id); + } + + /// + /// 告警上报统一入口(供其它模块/第三方系统跨进程调用;进程内模块请直接注入 IAlertCenterService) + /// + [HttpPost("raise")] + public async Task> Raise([FromBody] AlertRaiseDto dto) + { + return await _alertCenterService.RaiseAsync(dto); + } + + /// + /// 确认告警 + /// + [HttpPost("{id}/ack")] + public async Task> Acknowledge(long id, [FromBody] AlertOperateDto dto) + { + return await _alertMessageService.AcknowledgeAsync(id, dto); + } + + /// + /// 处理告警 + /// + [HttpPost("{id}/handle")] + public async Task> Handle(long id, [FromBody] AlertOperateDto dto) + { + return await _alertMessageService.HandleAsync(id, dto); + } + + /// + /// 关闭告警 + /// + [HttpPost("{id}/close")] + public async Task> Close(long id, [FromBody] AlertOperateDto dto) + { + return await _alertMessageService.CloseAsync(id, dto); + } + + /// + /// 忽略告警(误报等场景) + /// + [HttpPost("{id}/ignore")] + public async Task> Ignore(long id, [FromBody] AlertOperateDto dto) + { + return await _alertMessageService.IgnoreAsync(id, dto); + } + + /// + /// 转工单(一期仅变更状态并记录工单Id,工单模块二期对接) + /// + [HttpPost("{id}/to-workorder")] + public async Task> ToWorkOrder(long id, [FromBody] AlertOperateDto dto) + { + return await _alertMessageService.ToWorkOrderAsync(id, dto); + } + + /// + /// 告警概览(未确认数/今日新增/今日紧急/近7日总数) + /// + [HttpGet("statistics/overview")] + public async Task> GetOverview() + { + return await _alertMessageService.GetOverviewAsync(); + } + + /// + /// 告警趋势统计(granularity:day/week/month) + /// + [HttpGet("statistics/trend")] + public async Task>> GetTrend(string granularity = "day", + DateTime? startDate = null, DateTime? endDate = null) + { + return await _alertMessageService.GetTrendAsync(granularity, startDate, endDate); + } + + /// + /// 告警分布统计(dimension:device/type/level/source) + /// + [HttpGet("statistics/distribution")] + public async Task>> GetDistribution(string dimension = "level", + DateTime? startDate = null, DateTime? endDate = null) + { + return await _alertMessageService.GetDistributionAsync(dimension, startDate, endDate); + } + + /// + /// 高频告警 TOP 排名 + /// + [HttpGet("statistics/top")] + public async Task>> GetTop(int topN = 10, + DateTime? startDate = null, DateTime? endDate = null) + { + return await _alertMessageService.GetTopAsync(topN, startDate, endDate); + } + + /// + /// 告警处理及时率统计 + /// + [HttpGet("statistics/timeliness")] + public async Task> GetTimeliness(DateTime? startDate = null, DateTime? endDate = null) + { + return await _alertMessageService.GetTimelinessAsync(startDate, endDate); + } } } diff --git a/IOT_API/Controllers/Inspection/AlertNotifyController.cs b/IOT_API/Controllers/Inspection/AlertNotifyController.cs new file mode 100644 index 0000000..d05a2cb --- /dev/null +++ b/IOT_API/Controllers/Inspection/AlertNotifyController.cs @@ -0,0 +1,196 @@ +using Microsoft.AspNetCore.Mvc; +using Model; +using Model.Dto.Inspection; +using Service.Interface; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace WebAPI.Controllers +{ + /// + /// 告警通知配置(渠道管理 / 通知规则 / 推送日志 / 值班排班 / 跨网发件箱) + /// + [ApiController] + [Route("api/inspection/alert-notify")] + public class AlertNotifyController : ControllerBase + { + private readonly IAlertNotifyService _alertNotifyService; + + public AlertNotifyController(IAlertNotifyService alertNotifyService) + { + _alertNotifyService = alertNotifyService; + } + + #region 通知渠道 + /// + /// 渠道列表(Secret 脱敏) + /// + [HttpGet("channel/list")] + public async Task>> GetChannels() + { + return await _alertNotifyService.GetChannelsAsync(); + } + + /// + /// 新增渠道 + /// + [HttpPost("channel")] + public async Task> AddChannel([FromBody] AlertNotifyChannelDto dto) + { + return await _alertNotifyService.AddChannelAsync(dto); + } + + /// + /// 修改渠道(Secret 传空表示保持原值) + /// + [HttpPut("channel/{id}")] + public async Task> UpdateChannel(long id, [FromBody] AlertNotifyChannelDto dto) + { + dto.Id = id.ToString(); + return await _alertNotifyService.UpdateChannelAsync(dto); + } + + /// + /// 删除渠道(软删除) + /// + [HttpDelete("channel/{id}")] + public async Task> DeleteChannel(long id) + { + return await _alertNotifyService.DeleteChannelAsync(id); + } + + /// + /// 渠道测试推送(验证 Webhook 配置是否正确) + /// + [HttpPost("channel/{id}/test")] + public async Task> TestChannel(long id) + { + return await _alertNotifyService.TestSendAsync(id); + } + #endregion + + #region 通知规则 + /// + /// 通知规则列表 + /// + [HttpGet("rule/list")] + public async Task>> GetRules() + { + return await _alertNotifyService.GetRulesAsync(); + } + + /// + /// 新增通知规则 + /// + [HttpPost("rule")] + public async Task> AddRule([FromBody] AlertNotifyRuleDto dto) + { + return await _alertNotifyService.AddRuleAsync(dto); + } + + /// + /// 修改通知规则 + /// + [HttpPut("rule/{id}")] + public async Task> UpdateRule(long id, [FromBody] AlertNotifyRuleDto dto) + { + dto.Id = id.ToString(); + return await _alertNotifyService.UpdateRuleAsync(dto); + } + + /// + /// 删除通知规则(软删除) + /// + [HttpDelete("rule/{id}")] + public async Task> DeleteRule(long id) + { + return await _alertNotifyService.DeleteRuleAsync(id); + } + + /// + /// 启用/停用通知规则 + /// + [HttpPut("rule/{id}/enabled")] + public async Task> SetRuleEnabled(long id, [FromQuery] bool enabled) + { + return await _alertNotifyService.SetRuleEnabledAsync(id, enabled); + } + #endregion + + #region 推送日志 + /// + /// 推送日志分页(可按成功状态/渠道筛选;总记录数见响应头 X-Total-Count) + /// + [HttpGet("log/list")] + public async Task>> GetLogs(int pageIndex = 1, int pageSize = 10, + bool? success = null, long channelId = 0) + { + RefAsync total = 0; + var result = await _alertNotifyService.GetLogsPagedAsync(pageIndex, pageSize, total, success, channelId); + Response.Headers["X-Total-Count"] = total.Value.ToString(); + return result; + } + #endregion + + #region 值班排班 + /// + /// 值班排班列表(按日期范围,缺省本月) + /// + [HttpGet("duty/list")] + public async Task>> GetDuties(DateTime? startDate = null, DateTime? endDate = null) + { + return await _alertNotifyService.GetDutiesAsync(startDate, endDate); + } + + /// + /// 新增排班 + /// + [HttpPost("duty")] + public async Task> AddDuty([FromBody] DutyRosterDto dto) + { + return await _alertNotifyService.AddDutyAsync(dto); + } + + /// + /// 修改排班 + /// + [HttpPut("duty/{id}")] + public async Task> UpdateDuty(long id, [FromBody] DutyRosterDto dto) + { + dto.Id = id.ToString(); + return await _alertNotifyService.UpdateDutyAsync(dto); + } + + /// + /// 删除排班(软删除) + /// + [HttpDelete("duty/{id}")] + public async Task> DeleteDuty(long id) + { + return await _alertNotifyService.DeleteDutyAsync(id); + } + #endregion + + #region 跨网发件箱(AlertBridge 跳板机程序对接,非前端页面使用) + /// + /// 拉取待推送发件箱(AlertBridge 轮询调用;返回自包含推送报文) + /// + [HttpGet("outbox/pull")] + public async Task>> PullOutbox([FromQuery] int batch = 20) + { + return await _alertNotifyService.PullOutboxAsync(batch); + } + + /// + /// 回写发件箱推送结果(AlertBridge 推送完成后调用) + /// + [HttpPost("outbox/{id}/result")] + public async Task> ReportOutboxResult(long id, [FromBody] OutboxResultDto dto) + { + return await _alertNotifyService.ReportOutboxResultAsync(id, dto); + } + #endregion + } +} diff --git a/IOT_API/DependencyInjection.cs b/IOT_API/DependencyInjection.cs index 9dec826..b1bd0d9 100644 --- a/IOT_API/DependencyInjection.cs +++ b/IOT_API/DependencyInjection.cs @@ -1,7 +1,11 @@ using ORM; using Service.Implement; +using Service.Interface; using SqlSugar; +using System.Net.Http; +using System.Net.Security; using System.Reflection; +using System.Security.Authentication; namespace WebAPI { @@ -52,5 +56,38 @@ namespace WebAPI return services; } + + /// + /// 注册告警通知组件(飞书/钉钉/企微 Notifier,Typed HttpClient 模式) + /// 新增渠道:实现 IAlertNotifier 后在此追加一行注册即可,调用方零改动 + /// + public static IServiceCollection AddAlertNotification(this IServiceCollection services) + { + ConfigureNotifier(services.AddHttpClient()); + ConfigureNotifier(services.AddHttpClient()); + ConfigureNotifier(services.AddHttpClient()); + return services; + } + + /// + /// 通知渠道 HttpClient 统一配置: + /// - 超时 15s(默认 100s 过长,测试推送遇不可达地址会长时间无响应) + /// - 显式启用 TLS1.2/1.3(规避个别环境默认协议协商失败导致的 SSL 握手异常) + /// - 连接池生命周期 5 分钟(长驻服务定期回收连接,避免陈旧连接/DNS 漂移引发握手失败) + /// + private static void ConfigureNotifier(IHttpClientBuilder builder) + { + builder + .ConfigureHttpClient(c => c.Timeout = TimeSpan.FromSeconds(15)) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + }, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2) + }); + } } } diff --git a/IOT_API/Program.cs b/IOT_API/Program.cs index d46ca19..804d2f3 100644 --- a/IOT_API/Program.cs +++ b/IOT_API/Program.cs @@ -31,6 +31,10 @@ namespace WebAPI DatabaseConfig.SetTenant(tenantId); DatabaseConfig.CreateDatabaseAndCheckConnection(createDatabase: true, checkConnection: true); SqlSugarContext.InitDatabase(); + + // 飞书自建应用凭证:用于告警 @ 时把接收人手机号转成 open_id(webhook 机器人自身无此能力) + var feishu = builder.Configuration.GetSection("Feishu"); + Common.Notify.FeishuConfig.Init(feishu["AppId"] ?? "", feishu["AppSecret"] ?? ""); builder.Services.AddControllers(options => { // 非空字符串属性(如 Remark)缺失时不报 400,前端可以只传部分字段 @@ -47,6 +51,10 @@ namespace WebAPI // 自动注册业务服务(Service.Interface -> Service.Implement) builder.Services.AddBusinessServices(); + // 告警通知渠道(飞书/钉钉/企微 Notifier)+ 后台推送 Worker + builder.Services.AddAlertNotification(); + builder.Services.AddHostedService(); + // 温度箱模拟数据:每秒生成 100 条温湿度入库(真实设备接入后删除) builder.Services.AddHostedService(); diff --git a/IOT_API/Services/AlertNotifyWorker.cs b/IOT_API/Services/AlertNotifyWorker.cs new file mode 100644 index 0000000..838c05a --- /dev/null +++ b/IOT_API/Services/AlertNotifyWorker.cs @@ -0,0 +1,230 @@ +using Model.Entity.Inspection; +using ORM; +using Service.Implement; +using Service.Interface; +using SqlSugar; + +namespace WebAPI.Services +{ + /// + /// 告警通知后台 Worker:消费 AlertNotifyBus 队列,按通知规则分发到飞书/钉钉/企微渠道 + /// 流程:加载告警 → 匹配启用规则(按级别) → 静默期判断 → 直连推送(重试3次)或写跨网发件箱 → 记推送日志 + /// + public class AlertNotifyWorker : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// 直连推送最大尝试次数(含首次) + /// + private const int MaxAttempts = 3; + + public AlertNotifyWorker(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("告警通知 Worker 已启动"); + try + { + await foreach (var evt in AlertNotifyBus.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await ProcessEventAsync(evt, stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "处理告警通知事件失败, AlertId={AlertId}", evt.AlertId); + } + } + } + catch (OperationCanceledException) + { + // 正常停机 + } + } + + /// + /// 处理单个告警通知事件 + /// + private async Task ProcessEventAsync(AlertNotifyEvent evt, CancellationToken ct) + { + var db = SqlSugarContext.DbContext; + + // 1. 加载告警消息 + var alert = await db.Queryable() + .Where(x => x.Id == evt.AlertId && x.IsDel == 0) + .FirstAsync(); + if (alert == null) return; + + // 2. 匹配启用的通知规则(按告警级别) + var rules = await db.Queryable() + .Where(x => x.IsDel == 0 && x.IsEnabled && x.AlertLevel == alert.AlertLevel) + .ToListAsync(); + if (rules.Count == 0) return; + + // 3. 每个事件独立创建作用域(Worker 是单例,服务是 Scoped) + using var scope = _scopeFactory.CreateScope(); + var notifyService = scope.ServiceProvider.GetRequiredService(); + var notifiers = scope.ServiceProvider.GetServices().ToList(); + + foreach (var rule in rules) + { + var channel = await db.Queryable() + .Where(x => x.Id == rule.ChannelId && x.IsDel == 0 && x.IsEnabled) + .FirstAsync(); + if (channel == null) continue; + + // 4. 静默期判断:合并触发的重复告警在静默期内不重复推送(新告警必推) + if (!evt.IsNewAlert && rule.SilenceMinutes > 0) + { + var silenceStart = DateTime.Now.AddMinutes(-rule.SilenceMinutes); + bool recentlyPushed = await db.Queryable() + .AnyAsync(x => x.AlertId == alert.Id && x.ChannelId == channel.Id + && x.Success && x.NotifyTime >= silenceStart); + if (recentlyPushed) continue; + } + + // 5. 组装消息与 @ 列表(规则接收人 + 当班值班人电话) + var message = BuildMessage(alert); + var atMobiles = new List(); + if (!string.IsNullOrWhiteSpace(rule.Receivers)) + { + // 接收人常带 @ 前缀(如 "@18872509102"),但企微 mentioned_mobile_list / 钉钉 atMobiles 只认纯手机号, + // 带 @ 的非法值无法匹配群成员导致 @ 不生效,这里统一去掉前导 @ 与空白 + atMobiles.AddRange(rule.Receivers.Split(',', ',', ';', ';') + .Select(s => s.Trim().TrimStart('@').Trim()).Where(s => s.Length > 0)); + } + if (rule.NotifyDutyPerson) + { + var duty = await notifyService.GetDutyByDateAsync(alert.LastAlertTime.Date); + if (duty != null && !string.IsNullOrWhiteSpace(duty.Phone)) + { + atMobiles.Add(duty.Phone.Trim()); + message.Content += $"\n值班人: {duty.PersonName}"; + } + } + atMobiles = atMobiles.Distinct().ToList(); + + // 飞书渠道:at 标签只认 open_id,不认手机号——把接收人里的手机号转成 open_id + // ("all" 与 ou_ 开头的原样保留)。转换在内网主站完成,直连与跨网(Outbox)两条路径都拿到 open_id,AlertBridge 无需改。 + if (channel.ChannelType == NotifyChannelTypeEnum.Feishu && atMobiles.Count > 0) + { + var feishuContact = scope.ServiceProvider.GetRequiredService(); + var openIds = new List(); + foreach (var at in atMobiles) + { + if (at.Equals("all", StringComparison.OrdinalIgnoreCase) || at.StartsWith("ou_")) + { + openIds.Add(at); + } + else + { + var openId = await feishuContact.GetOpenIdByMobileAsync(at); + if (!string.IsNullOrEmpty(openId)) openIds.Add(openId); + else _logger.LogWarning("飞书接收人 {Receiver} 未换取到 open_id,已跳过 @(检查飞书应用权限与通讯录范围)", at); + } + } + atMobiles = openIds.Distinct().ToList(); + } + + // 关键:@ 列表必须回填到 message——直连模式的 Notifier 是从 message.AtMobiles 读取的。 + // 此前只把 atMobiles 传给了 Outbox 分支,直连 SendAsync(message) 的 message.AtMobiles 始终为空,导致企微/钉钉不 @ 任何人。 + message.AtMobiles = atMobiles; + + // 6. 按渠道推送模式分发 + if (channel.PushMode == NotifyPushModeEnum.Outbox) + { + // 跨网模式:写发件箱,由跳板机 AlertBridge 拉取推送并回写结果 + await notifyService.EnqueueOutboxAsync(alert.Id, channel.Id, message, atMobiles); + _logger.LogInformation("告警 {AlertId} 已转入跨网发件箱(渠道 {Channel})", alert.Id, channel.Name); + continue; + } + + // 直连模式:查找对应 Notifier,失败重试 + var notifier = notifiers.FirstOrDefault(n => n.ChannelType == channel.ChannelType); + if (notifier == null) + { + await notifyService.WriteLogAsync(alert.Id, channel.Id, message.Title, + false, $"暂不支持的渠道类型: {channel.ChannelType}", 0); + continue; + } + + NotifyResult result = NotifyResult.Fail("未执行"); + int attempt = 0; + while (attempt < MaxAttempts && !ct.IsCancellationRequested) + { + attempt++; + result = await notifier.SendAsync(message, channel.WebhookUrl, channel.Secret, ct); + if (result.Success) break; + if (attempt < MaxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct); // 2s/4s 退避 + } + } + + await notifyService.WriteLogAsync(alert.Id, channel.Id, + message.Title + " " + Truncate(message.Content, 800), + result.Success, result.ErrorMsg, attempt - 1); + + if (!result.Success) + { + _logger.LogWarning("告警 {AlertId} 推送渠道 {Channel} 失败: {Error}", alert.Id, channel.Name, result.ErrorMsg); + } + } + } + + /// + /// 按内置模板构建通知消息(标题 + markdown 正文;后续可由通知模板模块接管) + /// + private static AlertNotifyMessage BuildMessage(AlertMessageEntity alert) + { + string levelLabel = AlertLevelMap.ToLabel(alert.AlertLevel); + var content = new System.Text.StringBuilder(); + content.AppendLine($"- 设备: {alert.DeviceName}({alert.DeviceCode})"); + if (!string.IsNullOrWhiteSpace(alert.DeviceType)) content.AppendLine($"- 类型: {alert.DeviceType}"); + if (!string.IsNullOrWhiteSpace(alert.Metric)) content.AppendLine($"- 测点: {alert.Metric}"); + if (alert.CurrentValue.HasValue) content.AppendLine($"- 当前值: {alert.CurrentValue.Value}"); + if (alert.ThresholdValue.HasValue) content.AppendLine($"- 阈值: {alert.ThresholdValue.Value}"); + content.AppendLine($"- 告警类型: {TypeLabel(alert.AlertType)}"); + content.AppendLine($"- 来源: {SourceLabel(alert.Source)}"); + content.AppendLine($"- 内容: {alert.Content}"); + if (alert.TriggerCount > 1) content.AppendLine($"- 累计触发: {alert.TriggerCount} 次"); + content.Append($"- 时间: {alert.LastAlertTime:yyyy-MM-dd HH:mm:ss}"); + + return new AlertNotifyMessage + { + Title = $"【{levelLabel}】设备告警 - {alert.DeviceName ?? alert.DeviceCode}", + Content = content.ToString() + }; + } + + private static string TypeLabel(AlertTypeEnum type) => type switch + { + AlertTypeEnum.ThresholdExceed => "阈值超限", + AlertTypeEnum.DeviceOffline => "设备离线", + AlertTypeEnum.CommFault => "通讯故障", + AlertTypeEnum.PatrolAbnormal => "巡检异常", + AlertTypeEnum.Custom => "自定义", + _ => "其它" + }; + + private static string SourceLabel(AlertSourceEnum source) => source switch + { + AlertSourceEnum.RuleEngine => "规则引擎", + AlertSourceEnum.AutoPatrol => "自动巡检", + AlertSourceEnum.Dashboard => "数据看板", + AlertSourceEnum.Collection => "数据采集", + AlertSourceEnum.Manual => "手动上报", + _ => "其它" + }; + + private static string Truncate(string s, int max) => + string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "..."; + } +} diff --git a/IOT_API/Services/TemperatureBoxSimulator.cs b/IOT_API/Services/TemperatureBoxSimulator.cs index 6cc33a8..4d2c676 100644 --- a/IOT_API/Services/TemperatureBoxSimulator.cs +++ b/IOT_API/Services/TemperatureBoxSimulator.cs @@ -1,5 +1,7 @@ +using Model.Dto.Inspection; using Model.Entity.Inspection; using ORM; +using Service.Interface; namespace WebAPI.Services { @@ -22,9 +24,11 @@ namespace WebAPI.Services } private readonly Dictionary _states = new(); + private readonly IServiceScopeFactory _scopeFactory; - public TemperatureBoxSimulator() + public TemperatureBoxSimulator(IServiceScopeFactory scopeFactory) { + _scopeFactory = scopeFactory; for (int i = 1; i <= DeviceCount; i++) { _states[$"BOX-{i:D3}"] = new BoxState(); @@ -39,6 +43,7 @@ namespace WebAPI.Services { var now = DateTime.Now; var batch = new List(DeviceCount); + var alarmDevices = new List<(string Code, double Temp)>(); foreach (var kv in _states) { @@ -48,6 +53,10 @@ namespace WebAPI.Services s.Humidity = Math.Clamp(s.Humidity + (Rnd.NextDouble() - 0.5) * 3, 20, 90); bool alarm = s.Temperature >= AlarmTemp; + if (alarm) + { + alarmDevices.Add((kv.Key, Math.Round(s.Temperature, 1))); + } batch.Add(new TemperatureBoxDataEntity { DeviceCode = kv.Key, @@ -62,6 +71,30 @@ namespace WebAPI.Services // 批量插入 await SqlSugarContext.DbContext.Insertable(batch).ExecuteCommandAsync(); + + // 报警设备上报告警中心(告警中心内部有 5 分钟合并窗口,不会每秒重复建告警) + if (alarmDevices.Count > 0) + { + // 单例 HostedService 不能直接注入 Scoped 服务,按批次创建作用域解析 + using var scope = _scopeFactory.CreateScope(); + var alertCenter = scope.ServiceProvider.GetRequiredService(); + foreach (var (code, temp) in alarmDevices) + { + await alertCenter.RaiseAsync(new AlertRaiseDto + { + Source = AlertSourceEnum.Collection, + DeviceCode = code, + DeviceName = $"温度箱 {code}", + DeviceType = "TemperatureBox", + Metric = "Temperature", + AlertType = AlertTypeEnum.ThresholdExceed, + AlertLevel = AlertLevelEnum.Urgent, + CurrentValue = temp, + ThresholdValue = AlarmTemp, + Content = $"温度箱 {code} 温度超限:当前 {temp}℃ ≥ 报警阈值 {AlarmTemp}℃" + }); + } + } } catch (Exception ex) { diff --git a/Model/Dto/Inspection/AlertMessageDto.cs b/Model/Dto/Inspection/AlertMessageDto.cs new file mode 100644 index 0000000..26c6901 --- /dev/null +++ b/Model/Dto/Inspection/AlertMessageDto.cs @@ -0,0 +1,82 @@ +using Model.Entity.Inspection; + +namespace Model.Dto.Inspection +{ + /// + /// 告警消息 DTO(Id 转 string 防前端精度丢失) + /// + public class AlertMessageDto + { + /// 主键 Id + public string? Id { get; set; } + + /// 创建时间 + public DateTime? CreateTime { get; set; } + + /// 告警来源 + public AlertSourceEnum Source { get; set; } + + /// 关联告警规则Id("0"表示无) + public string? RuleId { get; set; } + + /// 设备编号 + public string? DeviceCode { get; set; } + + /// 设备名称 + public string? DeviceName { get; set; } + + /// 设备类型 + public string? DeviceType { get; set; } + + /// 测点/监测指标 + public string? Metric { get; set; } + + /// 告警类型 + public AlertTypeEnum AlertType { get; set; } + + /// 告警级别(1信息/2警告/3紧急) + public AlertLevelEnum AlertLevel { get; set; } + + /// 当前值 + public double? CurrentValue { get; set; } + + /// 阈值 + public double? ThresholdValue { get; set; } + + /// 告警内容描述 + public string? Content { get; set; } + + /// 通知模板编码(预留) + public string? TemplateCode { get; set; } + + /// 触发次数(合并窗口内累计) + public int TriggerCount { get; set; } + + /// 首次告警时间 + public DateTime FirstAlertTime { get; set; } + + /// 最近告警时间 + public DateTime LastAlertTime { get; set; } + + /// 告警状态(1未确认/2已确认/3已处理/4已关闭/5已忽略/6已转工单) + public AlertStatusEnum AlertStatus { get; set; } + + /// 确认人 + public string? AckBy { get; set; } + + /// 确认时间 + public DateTime? AckTime { get; set; } + + /// 处理人 + public string? HandleBy { get; set; } + + /// 处理时间 + public DateTime? HandleTime { get; set; } + + /// 处理备注 + public string? HandleRemark { get; set; } + + /// 关联工单Id(预留二期,"0"表示未转工单) + public string? WorkOrderId { get; set; } + } +} diff --git a/Model/Dto/Inspection/AlertNotifyDtos.cs b/Model/Dto/Inspection/AlertNotifyDtos.cs new file mode 100644 index 0000000..00354ae --- /dev/null +++ b/Model/Dto/Inspection/AlertNotifyDtos.cs @@ -0,0 +1,136 @@ +using Model.Entity.Inspection; + +namespace Model.Dto.Inspection +{ + /// + /// 通知渠道 DTO(Id 转 string;Secret 出口脱敏为是否已配置) + /// + public class AlertNotifyChannelDto + { + /// 主键 Id + public string? Id { get; set; } + + /// 创建时间 + public DateTime? CreateTime { get; set; } + + /// 渠道类型(1飞书/2钉钉/3企微) + public NotifyChannelTypeEnum ChannelType { get; set; } + + /// 渠道名称 + public string? Name { get; set; } + + /// Webhook 地址 + public string? WebhookUrl { get; set; } + + /// 加签密钥(入参可传;出口仅返回脱敏标记,不回传明文) + public string? Secret { get; set; } + + /// 是否已配置加签密钥(出口专用) + public bool HasSecret { get; set; } + + /// 推送模式(1直连/2Outbox跨网中转) + public NotifyPushModeEnum PushMode { get; set; } + + /// 是否启用 + public bool IsEnabled { get; set; } + + /// 备注 + public string? Remark { get; set; } + } + + /// + /// 通知规则 DTO + /// + public class AlertNotifyRuleDto + { + /// 主键 Id + public string? Id { get; set; } + + /// 创建时间 + public DateTime? CreateTime { get; set; } + + /// 规则名称 + public string? RuleName { get; set; } + + /// 适用告警级别(1信息/2警告/3紧急) + public AlertLevelEnum AlertLevel { get; set; } + + /// 通知渠道Id + public string? ChannelId { get; set; } + + /// 接收人(@手机号/账号,逗号分隔) + public string? Receivers { get; set; } + + /// 是否通知当班值班人 + public bool NotifyDutyPerson { get; set; } + + /// 静默期(分钟) + public int SilenceMinutes { get; set; } + + /// 是否启用 + public bool IsEnabled { get; set; } + + /// 备注 + public string? Remark { get; set; } + } + + /// + /// 推送日志 DTO + /// + public class AlertNotifyLogDto + { + /// 主键 Id + public string? Id { get; set; } + + /// 告警消息Id("0"表示渠道测试推送) + public string? AlertId { get; set; } + + /// 渠道类型 + public NotifyChannelTypeEnum ChannelType { get; set; } + + /// 渠道Id + public string? ChannelId { get; set; } + + /// 推送目标(脱敏) + public string? Target { get; set; } + + /// 推送内容摘要 + public string? Content { get; set; } + + /// 是否成功 + public bool Success { get; set; } + + /// 错误信息 + public string? ErrorMsg { get; set; } + + /// 重试次数 + public int RetryCount { get; set; } + + /// 推送时间 + public DateTime NotifyTime { get; set; } + } + + /// + /// 值班排班 DTO + /// + public class DutyRosterDto + { + /// 主键 Id + public string? Id { get; set; } + + /// 创建时间 + public DateTime? CreateTime { get; set; } + + /// 值班日期 + public DateTime DutyDate { get; set; } + + /// 值班人姓名 + public string? PersonName { get; set; } + + /// 联系电话 + public string? Phone { get; set; } + + /// 备注 + public string? Remark { get; set; } + } +} diff --git a/Model/Dto/Inspection/AlertOutboxDtos.cs b/Model/Dto/Inspection/AlertOutboxDtos.cs new file mode 100644 index 0000000..c9c1076 --- /dev/null +++ b/Model/Dto/Inspection/AlertOutboxDtos.cs @@ -0,0 +1,59 @@ +namespace Model.Dto.Inspection +{ + /// + /// 跨网发件箱拉取结果 DTO(AlertBridge 拉取到的待推送项;Payload 自包含全部推送要素) + /// + public class AlertOutboxItemDto + { + /// 发件箱 Id(回写结果时使用) + public string? Id { get; set; } + + /// 告警消息Id + public string? AlertId { get; set; } + + /// 渠道Id + public string? ChannelId { get; set; } + + /// 完整推送报文 JSON(AlertOutboxPayload 序列化) + public string? Payload { get; set; } + + /// 重试次数 + public int RetryCount { get; set; } + } + + /// + /// 发件箱推送报文(自包含:AlertBridge 无需访问数据库/配置即可推送) + /// + public class AlertOutboxPayload + { + /// 渠道类型(1飞书/2钉钉/3企微) + public int ChannelType { get; set; } + + /// Webhook 地址 + public string? WebhookUrl { get; set; } + + /// 加签密钥(可空) + public string? Secret { get; set; } + + /// 消息标题 + public string? Title { get; set; } + + /// 消息正文(markdown) + public string? Content { get; set; } + + /// @ 手机号列表 + public List? AtMobiles { get; set; } + } + + /// + /// 发件箱结果回写 DTO(AlertBridge 推送完成后回写) + /// + public class OutboxResultDto + { + /// 是否成功 + public bool Success { get; set; } + + /// 结果信息(失败原因) + public string? ResultMsg { get; set; } + } +} diff --git a/Model/Dto/Inspection/AlertRaiseDto.cs b/Model/Dto/Inspection/AlertRaiseDto.cs new file mode 100644 index 0000000..25a14a2 --- /dev/null +++ b/Model/Dto/Inspection/AlertRaiseDto.cs @@ -0,0 +1,62 @@ +using Model.Entity.Inspection; + +namespace Model.Dto.Inspection +{ + /// + /// 告警上报 DTO(其它模块调用告警中心的统一入参:IAlertCenterService.RaiseAsync / POST raise) + /// 除设备标识外全部可空,降低调用方接入成本 + /// + public class AlertRaiseDto + { + /// 告警来源(调用方标识自身模块) + public AlertSourceEnum Source { get; set; } = AlertSourceEnum.Other; + + /// 关联告警规则Id(来源为规则引擎时传,可空/0 表示无) + public long RuleId { get; set; } + + /// 设备编号(建议必传,用于去重合并) + public string? DeviceCode { get; set; } + + /// 设备名称 + public string? DeviceName { get; set; } + + /// 设备类型(如 TemperatureBox) + public string? DeviceType { get; set; } + + /// 测点/监测指标(如 Temperature) + public string? Metric { get; set; } + + /// 告警类型(默认阈值超限) + public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.ThresholdExceed; + + /// 告警级别(1信息/2警告/3紧急) + public AlertLevelEnum AlertLevel { get; set; } = AlertLevelEnum.Warning; + + /// 当前值 + public double? CurrentValue { get; set; } + + /// 阈值 + public double? ThresholdValue { get; set; } + + /// 告警内容描述(空则由告警中心按内置模板生成) + public string? Content { get; set; } + + /// 通知模板编码(预留对接通知模板模块) + public string? TemplateCode { get; set; } + } + + /// + /// 告警操作 DTO(确认/处理/关闭/忽略/转工单 共用入参) + /// + public class AlertOperateDto + { + /// 操作人 + public string? Operator { get; set; } + + /// 备注(处理说明/忽略原因等) + public string? Remark { get; set; } + + /// 工单Id(仅转工单时使用,二期工单模块创建后回填,可空) + public long WorkOrderId { get; set; } + } +} diff --git a/Model/Dto/Inspection/AlertStatisticsDtos.cs b/Model/Dto/Inspection/AlertStatisticsDtos.cs new file mode 100644 index 0000000..3b64561 --- /dev/null +++ b/Model/Dto/Inspection/AlertStatisticsDtos.cs @@ -0,0 +1,101 @@ +namespace Model.Dto.Inspection +{ + /// + /// 告警趋势统计项(按日/周/月分桶) + /// + public class AlertTrendItemDto + { + /// 时间桶(日:yyyy-MM-dd / 周:yyyy-Www / 月:yyyy-MM) + public string Bucket { get; set; } + + /// 告警总数 + public int Total { get; set; } + + /// 信息级数量 + public int InfoCount { get; set; } + + /// 警告级数量 + public int WarningCount { get; set; } + + /// 紧急级数量 + public int UrgentCount { get; set; } + + /// 未闭环数量(未确认+已确认未处理) + public int UnhandledCount { get; set; } + } + + /// + /// 告警分布统计项(按设备/类型/级别/来源维度) + /// + public class AlertDistributionItemDto + { + /// 维度名称 + public string Name { get; set; } + + /// 数量 + public int Count { get; set; } + } + + /// + /// 高频告警 TOP 排名项 + /// + public class AlertTopItemDto + { + /// 设备编号 + public string? DeviceCode { get; set; } + + /// 设备名称 + public string? DeviceName { get; set; } + + /// 告警次数(含合并累计 TriggerCount) + public int Count { get; set; } + + /// 最近告警时间 + public DateTime LastAlertTime { get; set; } + } + + /// + /// 告警处理及时率统计 + /// + public class AlertTimelinessDto + { + /// 统计范围内告警总数 + public int Total { get; set; } + + /// 已确认数(含已处理/已关闭) + public int AckCount { get; set; } + + /// 已处理/已关闭数 + public int HandledCount { get; set; } + + /// 未闭环数 + public int UnhandledCount { get; set; } + + /// 平均确认时长(分钟,无确认记录为 null) + public double? AvgAckMinutes { get; set; } + + /// 平均处理时长(分钟,无处理记录为 null) + public double? AvgHandleMinutes { get; set; } + + /// 及时率(%):30 分钟内确认的占比 + public double TimelyRate { get; set; } + } + + /// + /// 告警概览统计(列表页顶部卡片) + /// + public class AlertOverviewDto + { + /// 未确认数量 + public int UnacknowledgedCount { get; set; } + + /// 今日新增数量 + public int TodayCount { get; set; } + + /// 今日紧急数量 + public int TodayUrgentCount { get; set; } + + /// 近7日总数 + public int WeekCount { get; set; } + } +} diff --git a/Model/Entity/Inspection/AlertEnums.cs b/Model/Entity/Inspection/AlertEnums.cs new file mode 100644 index 0000000..876baae --- /dev/null +++ b/Model/Entity/Inspection/AlertEnums.cs @@ -0,0 +1,221 @@ +namespace Model.Entity.Inspection +{ + /// + /// 告警级别(与规则引擎 AlertRuleEntity.AlarmLevel 字符串"信息/警告/紧急"保持一致) + /// + public enum AlertLevelEnum + { + /// + /// 信息 + /// + Info = 1, + + /// + /// 警告 + /// + Warning = 2, + + /// + /// 紧急 + /// + Urgent = 3 + } + + /// + /// 告警状态(闭环流转:未确认 → 已确认 → 已处理 → 已关闭;可忽略/转工单) + /// + public enum AlertStatusEnum + { + /// + /// 未确认 + /// + Unacknowledged = 1, + + /// + /// 已确认 + /// + Acknowledged = 2, + + /// + /// 已处理 + /// + Handled = 3, + + /// + /// 已关闭 + /// + Closed = 4, + + /// + /// 已忽略 + /// + Ignored = 5, + + /// + /// 已转工单 + /// + ToWorkOrder = 6 + } + + /// + /// 告警来源(标识上报模块,便于其它模块调用与统计) + /// + public enum AlertSourceEnum + { + /// + /// 规则引擎 + /// + RuleEngine = 1, + + /// + /// 自动巡检 + /// + AutoPatrol = 2, + + /// + /// 数据看板 + /// + Dashboard = 3, + + /// + /// 数据采集 + /// + Collection = 4, + + /// + /// 手动/API 上报 + /// + Manual = 5, + + /// + /// 其它 + /// + Other = 99 + } + + /// + /// 告警类型 + /// + public enum AlertTypeEnum + { + /// + /// 阈值超限 + /// + ThresholdExceed = 1, + + /// + /// 设备离线 + /// + DeviceOffline = 2, + + /// + /// 通讯故障 + /// + CommFault = 3, + + /// + /// 巡检异常 + /// + PatrolAbnormal = 4, + + /// + /// 自定义 + /// + Custom = 99 + } + + /// + /// 通知渠道类型(一期仅支持飞书/钉钉/企微 Webhook 机器人) + /// + public enum NotifyChannelTypeEnum + { + /// + /// 飞书 + /// + Feishu = 1, + + /// + /// 钉钉 + /// + DingTalk = 2, + + /// + /// 企业微信 + /// + WeCom = 3 + } + + /// + /// 渠道推送模式 + /// + public enum NotifyPushModeEnum + { + /// + /// 直连推送(服务器可直接访问外网 Webhook) + /// + Direct = 1, + + /// + /// Outbox 中转(跨网场景:内网写发件箱,跳板机 AlertBridge 拉取后在外网推送) + /// + Outbox = 2 + } + + /// + /// 跨网发件箱状态 + /// + public enum OutboxStatusEnum + { + /// + /// 待推送 + /// + Pending = 1, + + /// + /// 已拉取(跳板机取走,等待回写结果) + /// + Pulled = 2, + + /// + /// 推送成功 + /// + Success = 3, + + /// + /// 推送失败 + /// + Failed = 4 + } + + /// + /// 告警级别枚举 ↔ 规则引擎字符串 映射工具 + /// + public static class AlertLevelMap + { + /// + /// 字符串(信息/警告/紧急)→ 枚举,无法识别时默认 Info + /// + public static AlertLevelEnum FromString(string level) + { + return level?.Trim() switch + { + "警告" => AlertLevelEnum.Warning, + "紧急" => AlertLevelEnum.Urgent, + _ => AlertLevelEnum.Info + }; + } + + /// + /// 枚举 → 字符串(信息/警告/紧急) + /// + public static string ToLabel(AlertLevelEnum level) + { + return level switch + { + AlertLevelEnum.Warning => "警告", + AlertLevelEnum.Urgent => "紧急", + _ => "信息" + }; + } + } +} diff --git a/Model/Entity/Inspection/AlertMessageEntity.cs b/Model/Entity/Inspection/AlertMessageEntity.cs new file mode 100644 index 0000000..9ae7ee4 --- /dev/null +++ b/Model/Entity/Inspection/AlertMessageEntity.cs @@ -0,0 +1,151 @@ +using SqlSugar; +using System; + +namespace Model.Entity.Inspection +{ + /// + /// 告警消息(消息告警模块核心表:所有模块的告警统一汇聚于此,支持确认/处理/关闭闭环) + /// + public class AlertMessageEntity : BaseEntity + { + #region 溯源信息 + /// + /// 告警来源(规则引擎/自动巡检/看板/数采/手动等) + /// + [SugarColumn(ColumnDescription = "告警来源")] + public AlertSourceEnum Source { get; set; } = AlertSourceEnum.Manual; + + /// + /// 关联告警规则Id(来源为规则引擎时填写,0表示无) + /// + [SugarColumn(ColumnDescription = "关联告警规则Id")] + public long RuleId { get; set; } + + /// + /// 设备编号 + /// + [SugarColumn(ColumnDescription = "设备编号", Length = 64, IsNullable = true)] + public string DeviceCode { get; set; } + + /// + /// 设备名称 + /// + [SugarColumn(ColumnDescription = "设备名称", Length = 128, IsNullable = true)] + public string DeviceName { get; set; } + + /// + /// 设备类型(如 TemperatureBox,与规则引擎 DeviceType 对齐) + /// + [SugarColumn(ColumnDescription = "设备类型", Length = 64, IsNullable = true)] + public string DeviceType { get; set; } + + /// + /// 测点/监测指标(如 Temperature、Humidity) + /// + [SugarColumn(ColumnDescription = "测点指标", Length = 64, IsNullable = true)] + public string Metric { get; set; } + + /// + /// 告警类型(超限/离线/通讯故障/巡检异常/自定义) + /// + [SugarColumn(ColumnDescription = "告警类型")] + public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.ThresholdExceed; + #endregion + + #region 告警内容 + /// + /// 告警级别(信息/警告/紧急,与规则引擎三级一致) + /// + [SugarColumn(ColumnDescription = "告警级别")] + public AlertLevelEnum AlertLevel { get; set; } = AlertLevelEnum.Info; + + /// + /// 当前值(触发告警时的实际值) + /// + [SugarColumn(ColumnDescription = "当前值", IsNullable = true)] + public double? CurrentValue { get; set; } + + /// + /// 阈值(规则阈值,可空) + /// + [SugarColumn(ColumnDescription = "阈值", IsNullable = true)] + public double? ThresholdValue { get; set; } + + /// + /// 告警内容描述 + /// + [SugarColumn(ColumnDescription = "告警内容", Length = 500, IsNullable = true)] + public string Content { get; set; } + + /// + /// 通知模板编码(预留:对接通知模板模块,空表示使用内置模板) + /// + [SugarColumn(ColumnDescription = "通知模板编码", Length = 64, IsNullable = true)] + public string TemplateCode { get; set; } + #endregion + + #region 聚合信息(去重合并窗口内累加) + /// + /// 触发次数(合并窗口内同设备同测点同类型告警的累计次数) + /// + [SugarColumn(ColumnDescription = "触发次数")] + public int TriggerCount { get; set; } = 1; + + /// + /// 首次告警时间 + /// + [SugarColumn(ColumnDescription = "首次告警时间")] + public DateTime FirstAlertTime { get; set; } + + /// + /// 最近告警时间 + /// + [SugarColumn(ColumnDescription = "最近告警时间")] + public DateTime LastAlertTime { get; set; } + #endregion + + #region 闭环信息 + /// + /// 告警状态(未确认/已确认/已处理/已关闭/已忽略/已转工单) + /// + [SugarColumn(ColumnDescription = "告警状态")] + public AlertStatusEnum AlertStatus { get; set; } = AlertStatusEnum.Unacknowledged; + + /// + /// 确认人 + /// + [SugarColumn(ColumnDescription = "确认人", Length = 50, IsNullable = true)] + public string AckBy { get; set; } + + /// + /// 确认时间 + /// + [SugarColumn(ColumnDescription = "确认时间", IsNullable = true)] + public DateTime? AckTime { get; set; } + + /// + /// 处理人 + /// + [SugarColumn(ColumnDescription = "处理人", Length = 50, IsNullable = true)] + public string HandleBy { get; set; } + + /// + /// 处理时间 + /// + [SugarColumn(ColumnDescription = "处理时间", IsNullable = true)] + public DateTime? HandleTime { get; set; } + + /// + /// 处理备注 + /// + [SugarColumn(ColumnDescription = "处理备注", Length = 500, IsNullable = true)] + public string HandleRemark { get; set; } + + /// + /// 关联工单Id(预留二期运维工单模块,0表示未转工单) + /// + [SugarColumn(ColumnDescription = "关联工单Id")] + public long WorkOrderId { get; set; } + #endregion + } +} diff --git a/Model/Entity/Inspection/AlertNotifyChannelEntity.cs b/Model/Entity/Inspection/AlertNotifyChannelEntity.cs new file mode 100644 index 0000000..150ef2d --- /dev/null +++ b/Model/Entity/Inspection/AlertNotifyChannelEntity.cs @@ -0,0 +1,52 @@ +using SqlSugar; + +namespace Model.Entity.Inspection +{ + /// + /// 告警通知渠道(飞书/钉钉/企微 Webhook 机器人配置) + /// + public class AlertNotifyChannelEntity : BaseEntity + { + /// + /// 渠道类型(1飞书/2钉钉/3企微) + /// + [SugarColumn(ColumnDescription = "渠道类型")] + public NotifyChannelTypeEnum ChannelType { get; set; } + + /// + /// 渠道名称(如:设备告警-研发飞书群) + /// + [SugarColumn(ColumnDescription = "渠道名称", Length = 100)] + public string Name { get; set; } + + /// + /// Webhook 地址 + /// + [SugarColumn(ColumnDescription = "Webhook地址", Length = 500)] + public string WebhookUrl { get; set; } + + /// + /// 加签密钥(钉钉/飞书机器人开启加签时填写,企微机器人无此项) + /// + [SugarColumn(ColumnDescription = "加签密钥", Length = 200, IsNullable = true)] + public string Secret { get; set; } + + /// + /// 推送模式(1直连推送/2Outbox跨网中转) + /// + [SugarColumn(ColumnDescription = "推送模式")] + public NotifyPushModeEnum PushMode { get; set; } = NotifyPushModeEnum.Direct; + + /// + /// 是否启用 + /// + [SugarColumn(ColumnDescription = "是否启用")] + public bool IsEnabled { get; set; } = true; + + /// + /// 备注 + /// + [SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)] + public string Remark { get; set; } + } +} diff --git a/Model/Entity/Inspection/AlertNotifyLogEntity.cs b/Model/Entity/Inspection/AlertNotifyLogEntity.cs new file mode 100644 index 0000000..431ddcd --- /dev/null +++ b/Model/Entity/Inspection/AlertNotifyLogEntity.cs @@ -0,0 +1,65 @@ +using SqlSugar; +using System; + +namespace Model.Entity.Inspection +{ + /// + /// 告警通知推送日志(每次向渠道推送的结果记录,失败可追溯) + /// + public class AlertNotifyLogEntity : BaseEntity + { + /// + /// 告警消息Id(关联 AlertMessageEntity.Id,0表示渠道测试推送) + /// + [SugarColumn(ColumnDescription = "告警消息Id")] + public long AlertId { get; set; } + + /// + /// 渠道类型(1飞书/2钉钉/3企微) + /// + [SugarColumn(ColumnDescription = "渠道类型")] + public NotifyChannelTypeEnum ChannelType { get; set; } + + /// + /// 渠道Id(关联 AlertNotifyChannelEntity.Id) + /// + [SugarColumn(ColumnDescription = "渠道Id")] + public long ChannelId { get; set; } + + /// + /// 推送目标(Webhook 地址脱敏展示) + /// + [SugarColumn(ColumnDescription = "推送目标", Length = 200, IsNullable = true)] + public string Target { get; set; } + + /// + /// 推送内容摘要 + /// + [SugarColumn(ColumnDescription = "推送内容", Length = 1000, IsNullable = true)] + public string Content { get; set; } + + /// + /// 是否成功 + /// + [SugarColumn(ColumnDescription = "是否成功")] + public bool Success { get; set; } + + /// + /// 错误信息 + /// + [SugarColumn(ColumnDescription = "错误信息", Length = 500, IsNullable = true)] + public string ErrorMsg { get; set; } + + /// + /// 重试次数 + /// + [SugarColumn(ColumnDescription = "重试次数")] + public int RetryCount { get; set; } + + /// + /// 推送时间 + /// + [SugarColumn(ColumnDescription = "推送时间")] + public DateTime NotifyTime { get; set; } + } +} diff --git a/Model/Entity/Inspection/AlertNotifyRuleEntity.cs b/Model/Entity/Inspection/AlertNotifyRuleEntity.cs new file mode 100644 index 0000000..5300082 --- /dev/null +++ b/Model/Entity/Inspection/AlertNotifyRuleEntity.cs @@ -0,0 +1,58 @@ +using SqlSugar; + +namespace Model.Entity.Inspection +{ + /// + /// 告警通知规则(按告警级别配置通知渠道与接收人,支持值班排班关联与静默期) + /// + public class AlertNotifyRuleEntity : BaseEntity + { + /// + /// 规则名称 + /// + [SugarColumn(ColumnDescription = "规则名称", Length = 100)] + public string RuleName { get; set; } + + /// + /// 适用告警级别(信息/警告/紧急) + /// + [SugarColumn(ColumnDescription = "适用告警级别")] + public AlertLevelEnum AlertLevel { get; set; } + + /// + /// 通知渠道Id(关联 AlertNotifyChannelEntity.Id) + /// + [SugarColumn(ColumnDescription = "通知渠道Id")] + public long ChannelId { get; set; } + + /// + /// 接收人(@手机号/企微账号,逗号分隔,可空表示不@) + /// + [SugarColumn(ColumnDescription = "接收人", Length = 500, IsNullable = true)] + public string Receivers { get; set; } + + /// + /// 是否通知当班值班人(按告警时间查值班排班表) + /// + [SugarColumn(ColumnDescription = "是否通知值班人")] + public bool NotifyDutyPerson { get; set; } + + /// + /// 静默期(分钟):同一告警合并窗口内重复触发时,静默期内不重复推送,0表示每次必推 + /// + [SugarColumn(ColumnDescription = "静默期分钟")] + public int SilenceMinutes { get; set; } = 5; + + /// + /// 是否启用 + /// + [SugarColumn(ColumnDescription = "是否启用")] + public bool IsEnabled { get; set; } = true; + + /// + /// 备注 + /// + [SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)] + public string Remark { get; set; } + } +} diff --git a/Model/Entity/Inspection/AlertOutboxEntity.cs b/Model/Entity/Inspection/AlertOutboxEntity.cs new file mode 100644 index 0000000..959e043 --- /dev/null +++ b/Model/Entity/Inspection/AlertOutboxEntity.cs @@ -0,0 +1,61 @@ +using SqlSugar; +using System; + +namespace Model.Entity.Inspection +{ + /// + /// 跨网推送发件箱(内网只写此表;跳板机上的 AlertBridge 控制台程序轮询拉取, + /// 在外网推送飞书/钉钉/企微后回写结果,实现工控内网与即时通讯软件的隔离穿透) + /// + public class AlertOutboxEntity : BaseEntity + { + /// + /// 告警消息Id(关联 AlertMessageEntity.Id) + /// + [SugarColumn(ColumnDescription = "告警消息Id")] + public long AlertId { get; set; } + + /// + /// 渠道Id(关联 AlertNotifyChannelEntity.Id) + /// + [SugarColumn(ColumnDescription = "渠道Id")] + public long ChannelId { get; set; } + + /// + /// 完整推送报文(自包含 JSON:渠道类型/WebhookUrl/Secret/标题/内容/@列表, + /// AlertBridge 拉取后无需再查任何配置即可直接推送) + /// + [SugarColumn(ColumnDescription = "推送报文", ColumnDataType = "text", IsNullable = true)] + public string Payload { get; set; } + + /// + /// 状态(1待推送/2已拉取/3成功/4失败) + /// + [SugarColumn(ColumnDescription = "状态")] + public OutboxStatusEnum Status { get; set; } = OutboxStatusEnum.Pending; + + /// + /// 重试次数(AlertBridge 推送失败回写时累加) + /// + [SugarColumn(ColumnDescription = "重试次数")] + public int RetryCount { get; set; } + + /// + /// 拉取时间 + /// + [SugarColumn(ColumnDescription = "拉取时间", IsNullable = true)] + public DateTime? PullTime { get; set; } + + /// + /// 完成时间(成功/失败回写时间) + /// + [SugarColumn(ColumnDescription = "完成时间", IsNullable = true)] + public DateTime? FinishTime { get; set; } + + /// + /// 结果信息(失败原因等) + /// + [SugarColumn(ColumnDescription = "结果信息", Length = 500, IsNullable = true)] + public string ResultMsg { get; set; } + } +} diff --git a/Model/Entity/Inspection/DutyRosterEntity.cs b/Model/Entity/Inspection/DutyRosterEntity.cs new file mode 100644 index 0000000..b55bad4 --- /dev/null +++ b/Model/Entity/Inspection/DutyRosterEntity.cs @@ -0,0 +1,35 @@ +using SqlSugar; +using System; + +namespace Model.Entity.Inspection +{ + /// + /// 值班排班(按天排班;通知规则勾选"通知值班人"时按告警日期匹配当班人) + /// + public class DutyRosterEntity : BaseEntity + { + /// + /// 值班日期(当天 00:00:00) + /// + [SugarColumn(ColumnDescription = "值班日期")] + public DateTime DutyDate { get; set; } + + /// + /// 值班人姓名 + /// + [SugarColumn(ColumnDescription = "值班人", Length = 50)] + public string PersonName { get; set; } + + /// + /// 联系电话(用于钉钉/企微 @ 手机号) + /// + [SugarColumn(ColumnDescription = "联系电话", Length = 30, IsNullable = true)] + public string Phone { get; set; } + + /// + /// 备注 + /// + [SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)] + public string Remark { get; set; } + } +} diff --git a/Model/Mapper/EntityMapper.cs b/Model/Mapper/EntityMapper.cs index 9f4724c..159ed4a 100644 --- a/Model/Mapper/EntityMapper.cs +++ b/Model/Mapper/EntityMapper.cs @@ -476,5 +476,275 @@ namespace Model.Mapper return entities?.Select(e => e.ToDto()).ToList() ?? new List(); } #endregion + + #region 告警消息 + /// + /// AlertMessageEntity → AlertMessageDto + /// + public static AlertMessageDto ToDto(this AlertMessageEntity entity) + { + if (entity == null) return null; + return new AlertMessageDto + { + Id = entity.Id.ToString(), + CreateTime = entity.CreateTime, + Source = entity.Source, + RuleId = entity.RuleId.ToString(), + DeviceCode = entity.DeviceCode, + DeviceName = entity.DeviceName, + DeviceType = entity.DeviceType, + Metric = entity.Metric, + AlertType = entity.AlertType, + AlertLevel = entity.AlertLevel, + CurrentValue = entity.CurrentValue, + ThresholdValue = entity.ThresholdValue, + Content = entity.Content, + TemplateCode = entity.TemplateCode, + TriggerCount = entity.TriggerCount, + FirstAlertTime = entity.FirstAlertTime, + LastAlertTime = entity.LastAlertTime, + AlertStatus = entity.AlertStatus, + AckBy = entity.AckBy, + AckTime = entity.AckTime, + HandleBy = entity.HandleBy, + HandleTime = entity.HandleTime, + HandleRemark = entity.HandleRemark, + WorkOrderId = entity.WorkOrderId.ToString() + }; + } + + /// + /// List<AlertMessageEntity> → List<AlertMessageDto> + /// + public static List ToDtoList(this List entities) + { + return entities?.Select(e => e.ToDto()).ToList() ?? new List(); + } + + /// + /// AlertRaiseDto → AlertMessageEntity(告警中心上报入参映射; + /// 状态/闭环/聚合字段由服务端初始化,不接受调用方指定) + /// + public static AlertMessageEntity ToEntity(this AlertRaiseDto dto) + { + if (dto == null) return null; + return new AlertMessageEntity + { + Source = dto.Source, + RuleId = dto.RuleId, + DeviceCode = dto.DeviceCode, + DeviceName = dto.DeviceName, + DeviceType = dto.DeviceType, + Metric = dto.Metric, + AlertType = dto.AlertType, + AlertLevel = dto.AlertLevel, + CurrentValue = dto.CurrentValue, + ThresholdValue = dto.ThresholdValue, + Content = dto.Content, + TemplateCode = dto.TemplateCode + }; + } + #endregion + + #region 告警通知渠道 + /// + /// AlertNotifyChannelEntity → AlertNotifyChannelDto(Secret 脱敏:不回传明文,仅标记 HasSecret) + /// + public static AlertNotifyChannelDto ToDto(this AlertNotifyChannelEntity entity) + { + if (entity == null) return null; + return new AlertNotifyChannelDto + { + Id = entity.Id.ToString(), + CreateTime = entity.CreateTime, + ChannelType = entity.ChannelType, + Name = entity.Name, + WebhookUrl = entity.WebhookUrl, + Secret = null, + HasSecret = !string.IsNullOrWhiteSpace(entity.Secret), + PushMode = entity.PushMode, + IsEnabled = entity.IsEnabled, + Remark = entity.Remark + }; + } + + /// + /// List<AlertNotifyChannelEntity> → List<AlertNotifyChannelDto> + /// + public static List ToDtoList(this List entities) + { + return entities?.Select(e => e.ToDto()).ToList() ?? new List(); + } + + /// + /// AlertNotifyChannelDto → AlertNotifyChannelEntity(IsDel/CreateTime 不映射) + /// + public static AlertNotifyChannelEntity ToEntity(this AlertNotifyChannelDto dto) + { + if (dto == null) return null; + return new AlertNotifyChannelEntity + { + Id = ParseId(dto.Id), + ChannelType = dto.ChannelType, + Name = dto.Name, + WebhookUrl = dto.WebhookUrl, + Secret = dto.Secret, + PushMode = dto.PushMode, + IsEnabled = dto.IsEnabled, + Remark = dto.Remark + }; + } + #endregion + + #region 告警通知规则 + /// + /// AlertNotifyRuleEntity → AlertNotifyRuleDto + /// + public static AlertNotifyRuleDto ToDto(this AlertNotifyRuleEntity entity) + { + if (entity == null) return null; + return new AlertNotifyRuleDto + { + Id = entity.Id.ToString(), + CreateTime = entity.CreateTime, + RuleName = entity.RuleName, + AlertLevel = entity.AlertLevel, + ChannelId = entity.ChannelId.ToString(), + Receivers = entity.Receivers, + NotifyDutyPerson = entity.NotifyDutyPerson, + SilenceMinutes = entity.SilenceMinutes, + IsEnabled = entity.IsEnabled, + Remark = entity.Remark + }; + } + + /// + /// List<AlertNotifyRuleEntity> → List<AlertNotifyRuleDto> + /// + public static List ToDtoList(this List entities) + { + return entities?.Select(e => e.ToDto()).ToList() ?? new List(); + } + + /// + /// AlertNotifyRuleDto → AlertNotifyRuleEntity(IsDel/CreateTime 不映射) + /// + public static AlertNotifyRuleEntity ToEntity(this AlertNotifyRuleDto dto) + { + if (dto == null) return null; + return new AlertNotifyRuleEntity + { + Id = ParseId(dto.Id), + RuleName = dto.RuleName, + AlertLevel = dto.AlertLevel, + ChannelId = ParseId(dto.ChannelId), + Receivers = dto.Receivers, + NotifyDutyPerson = dto.NotifyDutyPerson, + SilenceMinutes = dto.SilenceMinutes, + IsEnabled = dto.IsEnabled, + Remark = dto.Remark + }; + } + #endregion + + #region 告警通知日志 + /// + /// AlertNotifyLogEntity → AlertNotifyLogDto + /// + public static AlertNotifyLogDto ToDto(this AlertNotifyLogEntity entity) + { + if (entity == null) return null; + return new AlertNotifyLogDto + { + Id = entity.Id.ToString(), + AlertId = entity.AlertId.ToString(), + ChannelType = entity.ChannelType, + ChannelId = entity.ChannelId.ToString(), + Target = entity.Target, + Content = entity.Content, + Success = entity.Success, + ErrorMsg = entity.ErrorMsg, + RetryCount = entity.RetryCount, + NotifyTime = entity.NotifyTime + }; + } + + /// + /// List<AlertNotifyLogEntity> → List<AlertNotifyLogDto> + /// + public static List ToDtoList(this List entities) + { + return entities?.Select(e => e.ToDto()).ToList() ?? new List(); + } + #endregion + + #region 跨网发件箱 + /// + /// AlertOutboxEntity → AlertOutboxItemDto + /// + public static AlertOutboxItemDto ToDto(this AlertOutboxEntity entity) + { + if (entity == null) return null; + return new AlertOutboxItemDto + { + Id = entity.Id.ToString(), + AlertId = entity.AlertId.ToString(), + ChannelId = entity.ChannelId.ToString(), + Payload = entity.Payload, + RetryCount = entity.RetryCount + }; + } + + /// + /// List<AlertOutboxEntity> → List<AlertOutboxItemDto> + /// + public static List ToDtoList(this List entities) + { + return entities?.Select(e => e.ToDto()).ToList() ?? new List(); + } + #endregion + + #region 值班排班 + /// + /// DutyRosterEntity → DutyRosterDto + /// + public static DutyRosterDto ToDto(this DutyRosterEntity entity) + { + if (entity == null) return null; + return new DutyRosterDto + { + Id = entity.Id.ToString(), + CreateTime = entity.CreateTime, + DutyDate = entity.DutyDate, + PersonName = entity.PersonName, + Phone = entity.Phone, + Remark = entity.Remark + }; + } + + /// + /// List<DutyRosterEntity> → List<DutyRosterDto> + /// + public static List ToDtoList(this List entities) + { + return entities?.Select(e => e.ToDto()).ToList() ?? new List(); + } + + /// + /// DutyRosterDto → DutyRosterEntity(IsDel/CreateTime 不映射) + /// + public static DutyRosterEntity ToEntity(this DutyRosterDto dto) + { + if (dto == null) return null; + return new DutyRosterEntity + { + Id = ParseId(dto.Id), + DutyDate = dto.DutyDate.Date, + PersonName = dto.PersonName, + Phone = dto.Phone, + Remark = dto.Remark + }; + } + #endregion } } diff --git a/Service/Implement/Inspection/AlertCenterService.cs b/Service/Implement/Inspection/AlertCenterService.cs new file mode 100644 index 0000000..06320b7 --- /dev/null +++ b/Service/Implement/Inspection/AlertCenterService.cs @@ -0,0 +1,115 @@ +using Model; +using Model.Dto.Inspection; +using Model.Entity.Inspection; +using Model.Mapper; +using ORM; +using Service.Interface; +using SqlSugar; +using System; +using System.Threading.Tasks; + +namespace Service.Implement +{ + /// + /// 告警中心服务实现(全系统统一告警上报入口) + /// 职责:默认内容生成 → 去重合并(防告警风暴)→ 落库 → 规则触发计数 → 通知队列入队 + /// + public class AlertCenterService : IAlertCenterService + { + /// + /// 合并窗口(分钟):窗口内同设备+同测点+同类型的活跃告警不新建记录,只累加触发次数。 + /// 后续可迁移到系统参数配置模块动态调整 + /// + private const int MergeWindowMinutes = 5; + + /// + /// 上报一条告警 + /// + public async Task> RaiseAsync(AlertRaiseDto dto) + { + if (dto == null) + { + return Result.Error("告警上报参数为空"); + } + if (string.IsNullOrWhiteSpace(dto.DeviceCode) && string.IsNullOrWhiteSpace(dto.Content)) + { + return Result.Error("设备编号与告警内容不能同时为空"); + } + + try + { + var now = DateTime.Now; + + // ===== 去重合并:窗口内相同 设备+测点+类型 的活跃告警(未确认/已确认)只累加不新建 ===== + var active = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .Where(x => x.DeviceCode == dto.DeviceCode && x.Metric == dto.Metric && x.AlertType == dto.AlertType) + .Where(x => x.AlertStatus == AlertStatusEnum.Unacknowledged || x.AlertStatus == AlertStatusEnum.Acknowledged) + .Where(x => x.LastAlertTime >= now.AddMinutes(-MergeWindowMinutes)) + .FirstAsync(); + + if (active != null) + { + // 累加触发次数、刷新最近告警时间与当前值;级别取更高者(告警升级) + var mergedLevel = active.AlertLevel > dto.AlertLevel ? active.AlertLevel : dto.AlertLevel; + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => new AlertMessageEntity + { + TriggerCount = x.TriggerCount + 1, + LastAlertTime = now, + CurrentValue = dto.CurrentValue ?? x.CurrentValue, + AlertLevel = mergedLevel + }) + .Where(x => x.Id == active.Id) + .ExecuteCommandAsync(); + + // 合并告警也入队(是否真正推送由 Worker 按通知规则静默期决定) + AlertNotifyBus.Publish(active.Id, false); + return Result.Success(active.Id.ToString()); + } + + // ===== 新告警:生成默认内容 → 落库 → 规则计数 → 入队 ===== + var entity = dto.ToEntity(); + if (string.IsNullOrWhiteSpace(entity.Content)) + { + entity.Content = BuildDefaultContent(entity); + } + entity.CreateTime = now; + entity.FirstAlertTime = now; + entity.LastAlertTime = now; + entity.TriggerCount = 1; + entity.AlertStatus = AlertStatusEnum.Unacknowledged; + + await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync(); + + // 来源为规则引擎时,回写规则触发统计(与黄的规则引擎模块联动) + if (entity.RuleId > 0) + { + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => new AlertRuleEntity { TriggerCount = x.TriggerCount + 1, LastAlarmTime = now }) + .Where(x => x.Id == entity.RuleId) + .ExecuteCommandAsync(); + } + + AlertNotifyBus.Publish(entity.Id, true); + return Result.Success(entity.Id.ToString()); + } + catch (Exception ex) + { + return Result.Error("告警上报失败", ex); + } + } + + /// + /// 按内置模板生成默认告警内容(后续可由通知模板模块接管,见 TemplateCode 预留字段) + /// + private static string BuildDefaultContent(AlertMessageEntity e) + { + string device = string.IsNullOrWhiteSpace(e.DeviceName) ? e.DeviceCode ?? "未知设备" : $"{e.DeviceName}({e.DeviceCode})"; + string metric = string.IsNullOrWhiteSpace(e.Metric) ? "" : $" 测点[{e.Metric}]"; + string value = e.CurrentValue.HasValue ? $" 当前值 {e.CurrentValue.Value}" : ""; + string threshold = e.ThresholdValue.HasValue ? $",阈值 {e.ThresholdValue.Value}" : ""; + return $"{device}{metric} 触发告警:{value}{threshold}"; + } + } +} diff --git a/Service/Implement/Inspection/AlertMessageService.cs b/Service/Implement/Inspection/AlertMessageService.cs index 5d58f2e..297b5be 100644 --- a/Service/Implement/Inspection/AlertMessageService.cs +++ b/Service/Implement/Inspection/AlertMessageService.cs @@ -1,12 +1,412 @@ +using Model; +using Model.Dto.Inspection; +using Model.Entity.Inspection; +using Model.Mapper; +using ORM; using Service.Interface; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; namespace Service.Implement { /// - /// 消息告警 服务实现 + /// 消息告警 服务实现(告警列表/闭环操作/统计分析) /// public class AlertMessageService : IAlertMessageService { - // TODO: 实现 消息告警 相关方法 + #region 查询 + /// + /// 分页查询告警(支持级别/状态/类型/来源/设备/关键字/时间范围筛选) + /// + public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, + AlertLevelEnum? level, AlertStatusEnum? status, AlertTypeEnum? type, AlertSourceEnum? source, + string? deviceCode, string? keyword, DateTime? startDate, DateTime? endDate) + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .WhereIF(level.HasValue, x => x.AlertLevel == level!.Value) + .WhereIF(status.HasValue, x => x.AlertStatus == status!.Value) + .WhereIF(type.HasValue, x => x.AlertType == type!.Value) + .WhereIF(source.HasValue, x => x.Source == source!.Value) + .WhereIF(!string.IsNullOrWhiteSpace(deviceCode), x => x.DeviceCode == deviceCode) + .WhereIF(!string.IsNullOrWhiteSpace(keyword), + x => x.DeviceCode!.Contains(keyword!) || x.DeviceName!.Contains(keyword!) || x.Content!.Contains(keyword!)) + .WhereIF(startDate.HasValue, x => x.FirstAlertTime >= startDate!.Value) + .WhereIF(endDate.HasValue, x => x.FirstAlertTime <= endDate!.Value) + .OrderBy(x => x.LastAlertTime, OrderByType.Desc) + .ToPageListAsync(pageIndex, pageSize, total); + // total 保持为总记录数(前端分页组件需要) + return Result>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("分页查询告警失败", ex); + } + } + + /// + /// 告警详情 + /// + public async Task> GetByIdAsync(long id) + { + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0) + .FirstAsync(); + if (entity == null) + { + return Result.Error("告警不存在或已被删除"); + } + return Result.Success(entity.ToDto()); + } + catch (Exception ex) + { + return Result.Error("查询告警详情失败", ex); + } + } + #endregion + + #region 闭环操作 + /// + /// 确认告警 + /// + public Task> AcknowledgeAsync(long id, AlertOperateDto dto) + { + return OperateAsync(id, dto, AlertStatusEnum.Acknowledged, setAck: true, setHandle: false); + } + + /// + /// 处理告警(未确认时同时补记确认信息,处理即视为已确认) + /// + public Task> HandleAsync(long id, AlertOperateDto dto) + { + return OperateAsync(id, dto, AlertStatusEnum.Handled, setAck: true, setHandle: true); + } + + /// + /// 关闭告警(处理完成后归档) + /// + public Task> CloseAsync(long id, AlertOperateDto dto) + { + return OperateAsync(id, dto, AlertStatusEnum.Closed, setAck: false, setHandle: true); + } + + /// + /// 忽略告警(误报等场景,记录忽略原因) + /// + public Task> IgnoreAsync(long id, AlertOperateDto dto) + { + return OperateAsync(id, dto, AlertStatusEnum.Ignored, setAck: false, setHandle: true); + } + + /// + /// 转工单(一期仅变更状态并记录 WorkOrderId,工单模块二期对接) + /// + public async Task> ToWorkOrderAsync(long id, AlertOperateDto dto) + { + var result = await OperateAsync(id, dto, AlertStatusEnum.ToWorkOrder, setAck: false, setHandle: false); + if (result.IsSuccess && dto?.WorkOrderId > 0) + { + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.WorkOrderId == dto.WorkOrderId) + .Where(x => x.Id == id) + .ExecuteCommandAsync(); + } + return result; + } + + /// + /// 闭环操作统一实现:校验终态 → 更新状态与操作人/时间 + /// + private async Task> OperateAsync(long id, AlertOperateDto dto, AlertStatusEnum target, bool setAck, bool setHandle) + { + if (id <= 0) + { + return Result.Error("告警 Id 无效"); + } + try + { + var entity = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0) + .FirstAsync(); + if (entity == null) + { + return Result.Error("告警不存在或已被删除"); + } + // 已关闭/已忽略为终态,不允许再流转 + if (entity.AlertStatus is AlertStatusEnum.Closed or AlertStatusEnum.Ignored) + { + return Result.Error($"告警已处于【{StatusLabel(entity.AlertStatus)}】终态,无法再变更"); + } + + var now = DateTime.Now; + string? op = dto?.Operator; + string? remark = dto?.Remark; + + var updater = SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.AlertStatus == target); + if (setAck && entity.AckTime == null) + { + updater = updater.SetColumns(x => new AlertMessageEntity { AckBy = op, AckTime = now }); + } + if (setHandle) + { + updater = updater.SetColumns(x => new AlertMessageEntity { HandleBy = op, HandleTime = now, HandleRemark = remark }); + } + var rows = await updater.Where(x => x.Id == id).ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("告警操作失败", ex); + } + } + #endregion + + #region 统计分析 + /// + /// 告警概览(列表页顶部卡片) + /// + public async Task> GetOverviewAsync() + { + try + { + var today = DateTime.Today; + var db = SqlSugarContext.DbContext; + var overview = new AlertOverviewDto + { + UnacknowledgedCount = await db.Queryable() + .Where(x => x.IsDel == 0 && x.AlertStatus == AlertStatusEnum.Unacknowledged).CountAsync(), + TodayCount = await db.Queryable() + .Where(x => x.IsDel == 0 && x.FirstAlertTime >= today).CountAsync(), + TodayUrgentCount = await db.Queryable() + .Where(x => x.IsDel == 0 && x.FirstAlertTime >= today && x.AlertLevel == AlertLevelEnum.Urgent).CountAsync(), + WeekCount = await db.Queryable() + .Where(x => x.IsDel == 0 && x.FirstAlertTime >= today.AddDays(-6)).CountAsync() + }; + return Result.Success(overview); + } + catch (Exception ex) + { + return Result.Error("查询告警概览失败", ex); + } + } + + /// + /// 趋势统计(granularity:day/week/month;缺省时间范围为近30天) + /// + public async Task>> GetTrendAsync(string granularity, DateTime? startDate, DateTime? endDate) + { + try + { + var (start, end) = NormalizeRange(startDate, endDate, 30); + // 告警经合并窗口去重后数据量可控,取必要列内存聚合(跨库兼容,避免各数据库日期函数差异) + var rows = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end) + .Select(x => new { x.FirstAlertTime, x.AlertLevel, x.AlertStatus }) + .ToListAsync(); + + var grouped = rows.GroupBy(x => BucketOf(x.FirstAlertTime, granularity)) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(g => new AlertTrendItemDto + { + Bucket = g.Key, + Total = g.Count(), + InfoCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Info), + WarningCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Warning), + UrgentCount = g.Count(x => x.AlertLevel == AlertLevelEnum.Urgent), + UnhandledCount = g.Count(x => x.AlertStatus is AlertStatusEnum.Unacknowledged or AlertStatusEnum.Acknowledged) + }).ToList(); + return Result>.Success(grouped); + } + catch (Exception ex) + { + return Result>.Error("查询告警趋势失败", ex); + } + } + + /// + /// 分布统计(dimension:device/type/level/source) + /// + public async Task>> GetDistributionAsync(string dimension, DateTime? startDate, DateTime? endDate) + { + try + { + var (start, end) = NormalizeRange(startDate, endDate, 30); + var rows = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end) + .Select(x => new { x.DeviceCode, x.DeviceName, x.AlertType, x.AlertLevel, x.Source }) + .ToListAsync(); + + List result = (dimension ?? "level").ToLower() switch + { + "device" => rows.GroupBy(x => string.IsNullOrWhiteSpace(x.DeviceName) ? x.DeviceCode ?? "未知设备" : $"{x.DeviceName}({x.DeviceCode})") + .Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count).Take(20).ToList(), + "type" => rows.GroupBy(x => TypeLabel(x.AlertType)) + .Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count).ToList(), + "source" => rows.GroupBy(x => SourceLabel(x.Source)) + .Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count).ToList(), + _ => rows.GroupBy(x => AlertLevelMap.ToLabel(x.AlertLevel)) + .Select(g => new AlertDistributionItemDto { Name = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count).ToList() + }; + return Result>.Success(result); + } + catch (Exception ex) + { + return Result>.Error("查询告警分布失败", ex); + } + } + + /// + /// 高频告警 TOP 排名(按设备聚合,次数含合并累计 TriggerCount) + /// + public async Task>> GetTopAsync(int topN, DateTime? startDate, DateTime? endDate) + { + try + { + if (topN <= 0 || topN > 100) topN = 10; + var (start, end) = NormalizeRange(startDate, endDate, 30); + var rows = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end) + .Select(x => new { x.DeviceCode, x.DeviceName, x.TriggerCount, x.LastAlertTime }) + .ToListAsync(); + + var top = rows.GroupBy(x => x.DeviceCode ?? "未知设备") + .Select(g => new AlertTopItemDto + { + DeviceCode = g.Key, + DeviceName = g.First().DeviceName, + Count = g.Sum(x => x.TriggerCount), + LastAlertTime = g.Max(x => x.LastAlertTime) + }) + .OrderByDescending(x => x.Count) + .Take(topN) + .ToList(); + return Result>.Success(top); + } + catch (Exception ex) + { + return Result>.Error("查询高频告警失败", ex); + } + } + + /// + /// 处理及时率统计(平均确认/处理时长;及时率 = 30分钟内确认的占比) + /// + public async Task> GetTimelinessAsync(DateTime? startDate, DateTime? endDate) + { + try + { + var (start, end) = NormalizeRange(startDate, endDate, 30); + var rows = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.FirstAlertTime >= start && x.FirstAlertTime <= end) + .Select(x => new { x.FirstAlertTime, x.AckTime, x.HandleTime, x.AlertStatus }) + .ToListAsync(); + + const int timelyMinutes = 30; + var acked = rows.Where(x => x.AckTime != null).ToList(); + var handled = rows.Where(x => x.HandleTime != null && + x.AlertStatus is AlertStatusEnum.Handled or AlertStatusEnum.Closed).ToList(); + + var result = new AlertTimelinessDto + { + Total = rows.Count, + AckCount = acked.Count, + HandledCount = handled.Count, + UnhandledCount = rows.Count(x => x.AlertStatus is AlertStatusEnum.Unacknowledged or AlertStatusEnum.Acknowledged), + AvgAckMinutes = acked.Count > 0 + ? Math.Round(acked.Average(x => (x.AckTime!.Value - x.FirstAlertTime).TotalMinutes), 1) + : null, + AvgHandleMinutes = handled.Count > 0 + ? Math.Round(handled.Average(x => (x.HandleTime!.Value - x.FirstAlertTime).TotalMinutes), 1) + : null, + TimelyRate = rows.Count > 0 + ? Math.Round(100.0 * acked.Count(x => (x.AckTime!.Value - x.FirstAlertTime).TotalMinutes <= timelyMinutes) / rows.Count, 1) + : 0 + }; + return Result.Success(result); + } + catch (Exception ex) + { + return Result.Error("查询及时率统计失败", ex); + } + } + #endregion + + #region 私有工具 + /// + /// 时间范围缺省处理:未传时默认近 defaultDays 天 + /// + private static (DateTime start, DateTime end) NormalizeRange(DateTime? startDate, DateTime? endDate, int defaultDays) + { + var end = endDate ?? DateTime.Now; + var start = startDate ?? end.AddDays(-defaultDays); + return (start, end); + } + + /// + /// 时间分桶(day:yyyy-MM-dd / week:yyyy-Www / month:yyyy-MM) + /// + private static string BucketOf(DateTime time, string granularity) + { + return (granularity ?? "day").ToLower() switch + { + "week" => $"{ISOWeek.GetYear(time)}-W{ISOWeek.GetWeekOfYear(time):D2}", + "month" => time.ToString("yyyy-MM"), + _ => time.ToString("yyyy-MM-dd") + }; + } + + /// + /// 告警状态中文标签 + /// + public static string StatusLabel(AlertStatusEnum status) => status switch + { + AlertStatusEnum.Unacknowledged => "未确认", + AlertStatusEnum.Acknowledged => "已确认", + AlertStatusEnum.Handled => "已处理", + AlertStatusEnum.Closed => "已关闭", + AlertStatusEnum.Ignored => "已忽略", + AlertStatusEnum.ToWorkOrder => "已转工单", + _ => "未知" + }; + + /// + /// 告警类型中文标签 + /// + private static string TypeLabel(AlertTypeEnum type) => type switch + { + AlertTypeEnum.ThresholdExceed => "阈值超限", + AlertTypeEnum.DeviceOffline => "设备离线", + AlertTypeEnum.CommFault => "通讯故障", + AlertTypeEnum.PatrolAbnormal => "巡检异常", + AlertTypeEnum.Custom => "自定义", + _ => "其它" + }; + + /// + /// 告警来源中文标签 + /// + private static string SourceLabel(AlertSourceEnum source) => source switch + { + AlertSourceEnum.RuleEngine => "规则引擎", + AlertSourceEnum.AutoPatrol => "自动巡检", + AlertSourceEnum.Dashboard => "数据看板", + AlertSourceEnum.Collection => "数据采集", + AlertSourceEnum.Manual => "手动上报", + _ => "其它" + }; + #endregion } } diff --git a/Service/Implement/Inspection/AlertNotifyBus.cs b/Service/Implement/Inspection/AlertNotifyBus.cs new file mode 100644 index 0000000..c98e50a --- /dev/null +++ b/Service/Implement/Inspection/AlertNotifyBus.cs @@ -0,0 +1,39 @@ +using System.Threading.Channels; + +namespace Service.Implement +{ + /// + /// 告警通知事件(AlertId:告警消息Id;IsNewAlert:是否新告警,false 表示合并触发的重复告警) + /// + /// 告警消息 Id + /// 是否新告警 + public record AlertNotifyEvent(long AlertId, bool IsNewAlert); + + /// + /// 告警通知内存总线(生产者:AlertCenterService 上报告警后入队;消费者:AlertNotifyWorker 后台推送) + /// 采用静态单例 Channel,与项目 SqlSugarContext.DbContext 静态单例风格一致; + /// 进程重启丢失队列可接受(告警已落库,页面仍可查询处理) + /// + public static class AlertNotifyBus + { + private static readonly Channel _channel = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + + /// + /// 队列读取端(供 AlertNotifyWorker 消费) + /// + public static ChannelReader Reader => _channel.Reader; + + /// + /// 发布告警通知事件(非阻塞,队列满不会发生——无界队列) + /// + public static void Publish(long alertId, bool isNewAlert) + { + _channel.Writer.TryWrite(new AlertNotifyEvent(alertId, isNewAlert)); + } + } +} diff --git a/Service/Implement/Inspection/AlertNotifyService.cs b/Service/Implement/Inspection/AlertNotifyService.cs new file mode 100644 index 0000000..be56281 --- /dev/null +++ b/Service/Implement/Inspection/AlertNotifyService.cs @@ -0,0 +1,591 @@ +using Model; +using Model.Dto.Inspection; +using Model.Entity.Inspection; +using Model.Mapper; +using ORM; +using Service.Interface; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Service.Implement +{ + /// + /// 告警通知配置服务实现(渠道/通知规则/推送日志/值班排班/跨网发件箱) + /// + public class AlertNotifyService : IAlertNotifyService + { + private readonly IEnumerable _notifiers; + + public AlertNotifyService(IEnumerable notifiers) + { + _notifiers = notifiers; + } + + #region 通知渠道 + /// + /// 渠道列表(Secret 脱敏,只返回 HasSecret 标记) + /// + public async Task>> GetChannelsAsync() + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .ToListAsync(); + return Result>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询通知渠道失败", ex); + } + } + + /// + /// 新增渠道 + /// + public async Task> AddChannelAsync(AlertNotifyChannelDto dto) + { + if (string.IsNullOrWhiteSpace(dto?.Name) || string.IsNullOrWhiteSpace(dto?.WebhookUrl)) + { + return Result.Error("渠道名称与 Webhook 地址不能为空"); + } + try + { + var entity = dto.ToEntity(); + entity.Id = 0; // 防止前端误传 Id,新增一律由雪花生成 + entity.CreateTime = DateTime.Now; + await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync(); + return Result.Success(true); + } + catch (Exception ex) + { + return Result.Error("新增通知渠道失败", ex); + } + } + + /// + /// 修改渠道(Secret 传空表示保持原值不覆盖——出口已脱敏,前端编辑时不回传明文) + /// + public async Task> UpdateChannelAsync(AlertNotifyChannelDto dto) + { + var entity = dto?.ToEntity(); + if (entity == null || entity.Id <= 0) + { + return Result.Error("渠道 Id 无效"); + } + try + { + var updater = SqlSugarContext.DbContext.Updateable() + .SetColumns(x => new AlertNotifyChannelEntity + { + ChannelType = entity.ChannelType, + Name = entity.Name, + WebhookUrl = entity.WebhookUrl, + PushMode = entity.PushMode, + IsEnabled = entity.IsEnabled, + Remark = entity.Remark + }); + // Secret 显式传值时才覆盖(空表示不修改) + if (!string.IsNullOrWhiteSpace(entity.Secret)) + { + updater = updater.SetColumns(x => x.Secret == entity.Secret); + } + var rows = await updater.Where(x => x.Id == entity.Id && x.IsDel == 0).ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("修改通知渠道失败", ex); + } + } + + /// + /// 删除渠道(软删除) + /// + public async Task> DeleteChannelAsync(long id) + { + try + { + var rows = await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsDel == 1) + .Where(x => x.Id == id) + .ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("删除通知渠道失败", ex); + } + } + + /// + /// 渠道测试推送(发送测试消息验证 Webhook 配置,结果写入推送日志) + /// + public async Task> TestSendAsync(long channelId) + { + try + { + var channel = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == channelId && x.IsDel == 0) + .FirstAsync(); + if (channel == null) + { + return Result.Error("渠道不存在或已被删除"); + } + + var notifier = _notifiers.FirstOrDefault(n => n.ChannelType == channel.ChannelType); + if (notifier == null) + { + return Result.Error($"暂不支持的渠道类型: {channel.ChannelType}"); + } + + var message = new AlertNotifyMessage + { + Title = "【测试】设备告警通知", + Content = $"这是一条来自 IOT 设备管理平台的测试消息。\n渠道: {channel.Name}\n时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}" + }; + + NotifyResult sendResult; + if (channel.PushMode == NotifyPushModeEnum.Outbox) + { + // Outbox 模式:测试消息也走发件箱,由 AlertBridge 实际推送 + await InsertOutboxAsync(0, channel, message, new List()); + sendResult = NotifyResult.Ok(); + } + else + { + sendResult = await notifier.SendAsync(message, channel.WebhookUrl, channel.Secret); + } + + await InsertLogAsync(0, channel, message.Title + " " + message.Content, sendResult, 0); + return sendResult.Success + ? Result.Success(true) + : Result.Error($"测试推送失败: {sendResult.ErrorMsg}"); + } + catch (Exception ex) + { + return Result.Error("测试推送失败", ex); + } + } + #endregion + + #region 通知规则 + /// + /// 规则列表 + /// + public async Task>> GetRulesAsync() + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .OrderBy(x => x.AlertLevel, OrderByType.Desc) + .OrderBy(x => x.CreateTime, OrderByType.Desc) + .ToListAsync(); + return Result>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询通知规则失败", ex); + } + } + + /// + /// 新增规则 + /// + public async Task> AddRuleAsync(AlertNotifyRuleDto dto) + { + if (string.IsNullOrWhiteSpace(dto?.RuleName)) + { + return Result.Error("规则名称不能为空"); + } + if (dto.ChannelId == null || !long.TryParse(dto.ChannelId, out var channelId) || channelId <= 0) + { + return Result.Error("请选择有效的通知渠道"); + } + try + { + var entity = dto.ToEntity(); + entity.Id = 0; + entity.CreateTime = DateTime.Now; + await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync(); + return Result.Success(true); + } + catch (Exception ex) + { + return Result.Error("新增通知规则失败", ex); + } + } + + /// + /// 修改规则 + /// + public async Task> UpdateRuleAsync(AlertNotifyRuleDto dto) + { + var entity = dto?.ToEntity(); + if (entity == null || entity.Id <= 0) + { + return Result.Error("规则 Id 无效"); + } + try + { + var rows = await SqlSugarContext.DbContext.Updateable(entity) + .IgnoreColumns(x => new { x.CreateTime, x.IsDel }) + .ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("修改通知规则失败", ex); + } + } + + /// + /// 删除规则(软删除) + /// + public async Task> DeleteRuleAsync(long id) + { + try + { + var rows = await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsDel == 1) + .Where(x => x.Id == id) + .ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("删除通知规则失败", ex); + } + } + + /// + /// 启用/停用规则 + /// + public async Task> SetRuleEnabledAsync(long id, bool enabled) + { + try + { + var rows = await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsEnabled == enabled) + .Where(x => x.Id == id) + .ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("更新规则启用状态失败", ex); + } + } + #endregion + + #region 推送日志 + /// + /// 推送日志分页(可按成功状态/渠道筛选) + /// + public async Task>> GetLogsPagedAsync(int pageIndex, int pageSize, RefAsync total, + bool? success, long channelId = 0) + { + try + { + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0) + .WhereIF(success.HasValue, x => x.Success == success!.Value) + .WhereIF(channelId > 0, x => x.ChannelId == channelId) + .OrderBy(x => x.NotifyTime, OrderByType.Desc) + .ToPageListAsync(pageIndex, pageSize, total); + return Result>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询推送日志失败", ex); + } + } + + /// + /// 写入推送日志(内部公共方法,Worker 也复用) + /// + internal async Task InsertLogAsync(long alertId, AlertNotifyChannelEntity channel, string content, NotifyResult result, int retryCount) + { + try + { + await SqlSugarContext.DbContext.Insertable(new AlertNotifyLogEntity + { + AlertId = alertId, + ChannelType = channel.ChannelType, + ChannelId = channel.Id, + Target = MaskUrl(channel.WebhookUrl), + Content = content.Length > 1000 ? content.Substring(0, 1000) : content, + Success = result.Success, + ErrorMsg = result.ErrorMsg, + RetryCount = retryCount, + NotifyTime = DateTime.Now, + CreateTime = DateTime.Now + }).ExecuteCommandAsync(); + } + catch + { + // 日志写入失败不影响主流程 + } + } + + /// + /// Webhook 地址脱敏(保留主机名与路径结构,隐藏末段令牌与查询参数) + /// 飞书令牌在路径末段(/hook/{token})、钉钉在 access_token、企微在 key,均需遮蔽,避免日志泄露密钥 + /// + internal static string MaskUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) return url; + try + { + var uri = new Uri(url); + var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + // 保留除末段外的路径,末段(如飞书 hook 令牌)用 *** 代替;查询串一律丢弃 + string maskedPath = segments.Length > 1 + ? "/" + string.Join("/", segments.Take(segments.Length - 1)) + "/***" + : "/***"; + return $"{uri.Scheme}://{uri.Host}{maskedPath}"; + } + catch + { + return url.Length > 60 ? url.Substring(0, 60) + "..." : url; + } + } + #endregion + + #region 值班排班 + /// + /// 值班排班列表(按日期范围,缺省查本月) + /// + public async Task>> GetDutiesAsync(DateTime? startDate, DateTime? endDate) + { + try + { + var start = startDate ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + var end = endDate ?? start.AddMonths(1).AddDays(-1); + var list = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.DutyDate >= start.Date && x.DutyDate <= end.Date) + .OrderBy(x => x.DutyDate, OrderByType.Asc) + .ToListAsync(); + return Result>.Success(list.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("查询值班排班失败", ex); + } + } + + /// + /// 新增排班 + /// + public async Task> AddDutyAsync(DutyRosterDto dto) + { + if (string.IsNullOrWhiteSpace(dto?.PersonName)) + { + return Result.Error("值班人不能为空"); + } + try + { + var entity = dto.ToEntity(); + entity.Id = 0; + entity.CreateTime = DateTime.Now; + await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync(); + return Result.Success(true); + } + catch (Exception ex) + { + return Result.Error("新增值班排班失败", ex); + } + } + + /// + /// 修改排班 + /// + public async Task> UpdateDutyAsync(DutyRosterDto dto) + { + var entity = dto?.ToEntity(); + if (entity == null || entity.Id <= 0) + { + return Result.Error("排班 Id 无效"); + } + try + { + var rows = await SqlSugarContext.DbContext.Updateable(entity) + .IgnoreColumns(x => new { x.CreateTime, x.IsDel }) + .ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("修改值班排班失败", ex); + } + } + + /// + /// 删除排班(软删除) + /// + public async Task> DeleteDutyAsync(long id) + { + try + { + var rows = await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => x.IsDel == 1) + .Where(x => x.Id == id) + .ExecuteCommandAsync(); + return Result.Success(rows > 0); + } + catch (Exception ex) + { + return Result.Error("删除值班排班失败", ex); + } + } + + /// + /// 查询指定日期的值班人 + /// + public async Task GetDutyByDateAsync(DateTime date) + { + return await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.DutyDate == date.Date) + .FirstAsync(); + } + #endregion + + #region 跨网发件箱(AlertBridge 对接) + /// + /// 写入推送日志(供 AlertNotifyWorker 直连推送后复用) + /// + public async Task WriteLogAsync(long alertId, long channelId, string content, bool success, string? errorMsg, int retryCount) + { + var channel = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == channelId) + .FirstAsync(); + if (channel == null) return; + await InsertLogAsync(alertId, channel, content, + success ? NotifyResult.Ok() : NotifyResult.Fail(errorMsg ?? "未知错误"), retryCount); + } + + /// + /// 写入跨网发件箱(供 AlertNotifyWorker 的 Outbox 模式复用) + /// + public async Task EnqueueOutboxAsync(long alertId, long channelId, AlertNotifyMessage message, List atMobiles) + { + var channel = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == channelId && x.IsDel == 0) + .FirstAsync(); + if (channel == null) return; + await InsertOutboxAsync(alertId, channel, message, atMobiles); + } + + /// + /// 拉取待推送发件箱(先查后标记;单跳板机实例场景足够,多实例并发时可改用数据库锁) + /// + public async Task>> PullOutboxAsync(int batch) + { + try + { + if (batch <= 0 || batch > 100) batch = 20; + var pending = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.IsDel == 0 && x.Status == OutboxStatusEnum.Pending) + .OrderBy(x => x.CreateTime, OrderByType.Asc) + .Take(batch) + .ToListAsync(); + if (pending.Count == 0) + { + return Result>.Success(new List()); + } + + // 标记已拉取(仅更新仍为 Pending 的记录,防止重复拉取) + var ids = pending.Select(x => x.Id).ToList(); + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => new AlertOutboxEntity { Status = OutboxStatusEnum.Pulled, PullTime = DateTime.Now }) + .Where(x => ids.Contains(x.Id) && x.Status == OutboxStatusEnum.Pending) + .ExecuteCommandAsync(); + + return Result>.Success(pending.ToDtoList()); + } + catch (Exception ex) + { + return Result>.Error("拉取发件箱失败", ex); + } + } + + /// + /// 回写发件箱推送结果(失败累加重试次数;结果同步写推送日志便于页面追溯) + /// + public async Task> ReportOutboxResultAsync(long id, OutboxResultDto dto) + { + try + { + var item = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == id && x.IsDel == 0) + .FirstAsync(); + if (item == null) + { + return Result.Error("发件箱记录不存在"); + } + + var status = dto?.Success == true ? OutboxStatusEnum.Success : OutboxStatusEnum.Failed; + // 表达式树不支持 ?. 空传播,先提取局部变量 + bool success = dto?.Success == true; + string? resultMsg = dto?.ResultMsg; + await SqlSugarContext.DbContext.Updateable() + .SetColumns(x => new AlertOutboxEntity + { + Status = status, + FinishTime = DateTime.Now, + ResultMsg = resultMsg, + RetryCount = success ? x.RetryCount : x.RetryCount + 1 + }) + .Where(x => x.Id == id) + .ExecuteCommandAsync(); + + // 推送日志(渠道信息回查,失败忽略) + var channel = await SqlSugarContext.DbContext.Queryable() + .Where(x => x.Id == item.ChannelId) + .FirstAsync(); + if (channel != null) + { + await InsertLogAsync(item.AlertId, channel, + $"[跨网中转] {(dto?.Success == true ? "推送成功" : "推送失败")}", + dto?.Success == true ? NotifyResult.Ok() : NotifyResult.Fail(dto?.ResultMsg ?? "未知错误"), + item.RetryCount); + } + return Result.Success(true); + } + catch (Exception ex) + { + return Result.Error("回写发件箱结果失败", ex); + } + } + + /// + /// 写入发件箱(Worker 的 Outbox 模式与渠道测试推送共用;Payload 自包含全部推送要素) + /// + internal async Task InsertOutboxAsync(long alertId, AlertNotifyChannelEntity channel, AlertNotifyMessage message, List atMobiles) + { + var payload = new AlertOutboxPayload + { + ChannelType = (int)channel.ChannelType, + WebhookUrl = channel.WebhookUrl, + Secret = channel.Secret, + Title = message.Title, + Content = message.Content, + AtMobiles = atMobiles + }; + await SqlSugarContext.DbContext.Insertable(new AlertOutboxEntity + { + AlertId = alertId, + ChannelId = channel.Id, + Payload = System.Text.Json.JsonSerializer.Serialize(payload), + Status = OutboxStatusEnum.Pending, + CreateTime = DateTime.Now + }).ExecuteCommandAsync(); + } + #endregion + } +} diff --git a/Service/Implement/Inspection/Notify/DingTalkNotifier.cs b/Service/Implement/Inspection/Notify/DingTalkNotifier.cs new file mode 100644 index 0000000..99cdfba --- /dev/null +++ b/Service/Implement/Inspection/Notify/DingTalkNotifier.cs @@ -0,0 +1,95 @@ +using Common.Notify; +using Model.Entity.Inspection; +using Service.Interface; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Service.Implement +{ + /// + /// 钉钉群机器人通知发送器(自定义机器人 Webhook,支持加签校验) + /// 报文:msgtype=markdown,at.atMobiles 支持 @手机号; + /// 加签:URL 追加 timestamp(毫秒)+sign(算法见 ImWebhookSigner.DingTalkSign) + /// + public class DingTalkNotifier : IAlertNotifier + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public DingTalkNotifier(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.DingTalk; + + public async Task SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default) + { + try + { + string url = webhookUrl; + // 加签:URL 追加 timestamp 与 sign 参数 + if (!string.IsNullOrWhiteSpace(secret)) + { + long timestampMs = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + string sign = ImWebhookSigner.DingTalkSign(timestampMs, secret); + url += $"{(url.Contains('?') ? "&" : "?")}timestamp={timestampMs}&sign={sign}"; + } + + // markdown 正文中 @ 手机号需同时出现在 atMobiles 与正文里才会真正提醒 + var text = new StringBuilder(); + text.AppendLine($"### {message.Title}"); + text.AppendLine(message.Content); + foreach (var mobile in message.AtMobiles) + { + text.Append($"@{mobile} "); + } + + var body = new Dictionary + { + ["msgtype"] = "markdown", + ["markdown"] = new Dictionary + { + ["title"] = message.Title, + ["text"] = text.ToString() + }, + ["at"] = new Dictionary + { + ["atMobiles"] = message.AtMobiles, + ["isAtAll"] = false + } + }; + + string json = JsonSerializer.Serialize(body); + _logger.LogInformation("钉钉推送 atMobiles=[{At}]", string.Join(",", message.AtMobiles)); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync(url, content, ct); + string respText = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}"); + } + + // 钉钉返回 {"errcode":0,"errmsg":"ok"} + using var doc = JsonDocument.Parse(respText); + bool ok = doc.RootElement.TryGetProperty("errcode", out var errcode) && errcode.GetInt32() == 0; + return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText)); + } + catch (Exception ex) + { + return NotifyResult.Fail($"钉钉推送异常: {NotifyError.Describe(ex)}"); + } + } + + private static string Truncate(string s, int max = 200) => + string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "..."; + } +} diff --git a/Service/Implement/Inspection/Notify/FeishuContactService.cs b/Service/Implement/Inspection/Notify/FeishuContactService.cs new file mode 100644 index 0000000..494807c --- /dev/null +++ b/Service/Implement/Inspection/Notify/FeishuContactService.cs @@ -0,0 +1,147 @@ +using Common.Notify; +using Microsoft.Extensions.Logging; +using Service.Interface; +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Service.Implement +{ + /// + /// 飞书通讯录服务实现:手机号 → open_id + /// 依赖飞书自建应用凭证(FeishuConfig);tenant_access_token 与 open_id 结果均做进程级内存缓存, + /// 避免每条告警都调用飞书 API(token 有效期约 7200s,open_id 基本不变)。 + /// + public class FeishuContactService : IFeishuContactService + { + private const string TokenUrl = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"; + private const string BatchGetIdUrl = "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + // tenant_access_token 进程级缓存(提前 5 分钟刷新,避免边界失效) + private static string? _token; + private static DateTime _tokenExpireAt = DateTime.MinValue; + private static readonly SemaphoreSlim _tokenLock = new(1, 1); + + // 手机号 → open_id 结果缓存(换不到的也缓存 null 避免反复调用;重启进程可重置) + private static readonly ConcurrentDictionary _openIdCache = new(); + + public FeishuContactService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + /// 用手机号换取飞书 open_id;未配置凭证、查无此人或无权限时返回 null + /// + public async Task GetOpenIdByMobileAsync(string mobile) + { + if (string.IsNullOrWhiteSpace(mobile)) return null; + mobile = mobile.Trim(); + + if (_openIdCache.TryGetValue(mobile, out var cached)) return cached; + + if (!FeishuConfig.IsConfigured) + { + _logger.LogWarning("飞书应用凭证未配置(AppId/AppSecret),无法把手机号 {Mobile} 转 open_id;请在 appsettings.json 的 Feishu 节配置", mobile); + return null; + } + + try + { + var token = await GetTenantAccessTokenAsync(); + if (string.IsNullOrEmpty(token)) return null; + + var http = _httpClientFactory.CreateClient(); + string body = JsonSerializer.Serialize(new { mobiles = new[] { mobile } }); + using var req = new HttpRequestMessage(HttpMethod.Post, BatchGetIdUrl); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + req.Content = new StringContent(body, Encoding.UTF8, "application/json"); + using var resp = await http.SendAsync(req); + string respText = await resp.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(respText); + var root = doc.RootElement; + int code = root.TryGetProperty("code", out var c) ? c.GetInt32() : -1; + if (code != 0) + { + string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : ""; + _logger.LogWarning("飞书手机号换 open_id 失败 mobile={Mobile} code={Code} msg={Msg}(多为权限未开通或通讯录范围不含该成员)", mobile, code, msg); + _openIdCache[mobile] = null; + return null; + } + + string? openId = null; + if (root.TryGetProperty("data", out var data) && + data.TryGetProperty("user_list", out var list) && + list.ValueKind == JsonValueKind.Array && list.GetArrayLength() > 0 && + list[0].TryGetProperty("user_id", out var uid)) + { + openId = uid.GetString(); + } + + if (string.IsNullOrEmpty(openId)) + _logger.LogWarning("飞书手机号 {Mobile} 未查到 open_id(该手机号可能不在应用通讯录范围内)", mobile); + + _openIdCache[mobile] = openId; + return openId; + } + catch (Exception ex) + { + _logger.LogError("飞书手机号换 open_id 异常 mobile={Mobile}: {Err}", mobile, NotifyError.Describe(ex)); + return null; + } + } + + /// + /// 获取 tenant_access_token(进程级缓存,提前 5 分钟刷新) + /// + private async Task GetTenantAccessTokenAsync() + { + if (!string.IsNullOrEmpty(_token) && DateTime.Now < _tokenExpireAt) return _token; + await _tokenLock.WaitAsync(); + try + { + if (!string.IsNullOrEmpty(_token) && DateTime.Now < _tokenExpireAt) return _token; + + var http = _httpClientFactory.CreateClient(); + string body = JsonSerializer.Serialize(new { app_id = FeishuConfig.AppId, app_secret = FeishuConfig.AppSecret }); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var resp = await http.PostAsync(TokenUrl, content); + string respText = await resp.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(respText); + var root = doc.RootElement; + int code = root.TryGetProperty("code", out var c) ? c.GetInt32() : -1; + if (code != 0) + { + string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : ""; + _logger.LogError("飞书获取 tenant_access_token 失败 code={Code} msg={Msg}(检查 AppId/AppSecret)", code, msg); + return null; + } + + _token = root.TryGetProperty("tenant_access_token", out var t) ? t.GetString() : null; + int expire = root.TryGetProperty("expire", out var e) ? e.GetInt32() : 7200; + _tokenExpireAt = DateTime.Now.AddSeconds(Math.Max(60, expire - 300)); + return _token; + } + catch (Exception ex) + { + _logger.LogError("飞书获取 tenant_access_token 异常: {Err}", NotifyError.Describe(ex)); + return null; + } + finally + { + _tokenLock.Release(); + } + } + } +} diff --git a/Service/Implement/Inspection/Notify/FeishuNotifier.cs b/Service/Implement/Inspection/Notify/FeishuNotifier.cs new file mode 100644 index 0000000..dea98a8 --- /dev/null +++ b/Service/Implement/Inspection/Notify/FeishuNotifier.cs @@ -0,0 +1,113 @@ +using Common.Notify; +using Model.Entity.Inspection; +using Service.Interface; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Service.Implement +{ + /// + /// 飞书群机器人通知发送器(自定义机器人 Webhook,支持加签校验) + /// 报文:msg_type=post 富文本,用 at 标签(user_id=open_id)真正 @ 人;加签时附 timestamp(秒)+sign(算法见 ImWebhookSigner.FeishuSign) + /// + public class FeishuNotifier : IAlertNotifier + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public FeishuNotifier(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.Feishu; + + public async Task SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default) + { + try + { + // 飞书 text 消息不支持结构化 @,必须用 post 富文本 + at 标签才能真正 @ 到人。 + // at 标签的 user_id 为飞书 open_id(ou_ 开头);如需 @ 所有人,接收人填 "all"。 + var contentLines = new List>>(); + foreach (var raw in (message.Content ?? string.Empty).Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.Length == 0) continue; + contentLines.Add(new List> + { + new Dictionary { ["tag"] = "text", ["text"] = line } + }); + } + if (message.AtMobiles.Count > 0) + { + var atLine = new List> + { + new Dictionary { ["tag"] = "text", ["text"] = "关注人: " } + }; + foreach (var openId in message.AtMobiles) + { + if (string.IsNullOrWhiteSpace(openId)) continue; + atLine.Add(new Dictionary { ["tag"] = "at", ["user_id"] = openId.Trim() }); + } + contentLines.Add(atLine); + } + + var body = new Dictionary + { + ["msg_type"] = "post", + ["content"] = new Dictionary + { + ["post"] = new Dictionary + { + ["zh_cn"] = new Dictionary + { + ["title"] = message.Title, + ["content"] = contentLines + } + } + } + }; + + // 加签:timestamp(秒) + sign + if (!string.IsNullOrWhiteSpace(secret)) + { + long timestampSec = DateTimeOffset.Now.ToUnixTimeSeconds(); + body["timestamp"] = timestampSec.ToString(); + body["sign"] = ImWebhookSigner.FeishuSign(timestampSec, secret); + } + + string json = JsonSerializer.Serialize(body); + _logger.LogInformation("飞书推送(post富文本) @open_id=[{At}]", string.Join(",", message.AtMobiles)); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync(webhookUrl, content, ct); + string respText = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}"); + } + + // 飞书返回 {"code":0,...}(旧版为 {"StatusCode":0,...}) + using var doc = JsonDocument.Parse(respText); + var root = doc.RootElement; + bool ok = (root.TryGetProperty("code", out var code) && code.GetInt32() == 0) + || (root.TryGetProperty("StatusCode", out var sc) && sc.GetInt32() == 0); + return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText)); + } + catch (Exception ex) + { + return NotifyResult.Fail($"飞书推送异常: {NotifyError.Describe(ex)}"); + } + } + + private static string Truncate(string s, int max = 200) => + string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "..."; + } +} diff --git a/Service/Implement/Inspection/Notify/WeComNotifier.cs b/Service/Implement/Inspection/Notify/WeComNotifier.cs new file mode 100644 index 0000000..53854c2 --- /dev/null +++ b/Service/Implement/Inspection/Notify/WeComNotifier.cs @@ -0,0 +1,75 @@ +using Model.Entity.Inspection; +using Service.Interface; +using Common.Notify; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Service.Implement +{ + /// + /// 企业微信群机器人通知发送器(群机器人 Webhook 无加签机制) + /// 报文:msgtype=text(text 类型才支持 mentioned_mobile_list @手机号) + /// + public class WeComNotifier : IAlertNotifier + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public WeComNotifier(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.WeCom; + + public async Task SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default) + { + try + { + var text = new StringBuilder(); + text.AppendLine(message.Title); + text.AppendLine(message.Content); + + var body = new Dictionary + { + ["msgtype"] = "text", + ["text"] = new Dictionary + { + ["content"] = text.ToString(), + ["mentioned_mobile_list"] = message.AtMobiles + } + }; + + string json = JsonSerializer.Serialize(body); + _logger.LogInformation("企微推送 mentioned_mobile_list=[{At}]", string.Join(",", message.AtMobiles)); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync(webhookUrl, content, ct); + string respText = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}"); + } + + // 企微返回 {"errcode":0,"errmsg":"ok"} + using var doc = JsonDocument.Parse(respText); + bool ok = doc.RootElement.TryGetProperty("errcode", out var errcode) && errcode.GetInt32() == 0; + return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText)); + } + catch (Exception ex) + { + return NotifyResult.Fail($"企微推送异常: {NotifyError.Describe(ex)}"); + } + } + + private static string Truncate(string s, int max = 200) => + string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "..."; + } +} diff --git a/Service/Interface/Inspection/IAlertCenterService.cs b/Service/Interface/Inspection/IAlertCenterService.cs new file mode 100644 index 0000000..620684b --- /dev/null +++ b/Service/Interface/Inspection/IAlertCenterService.cs @@ -0,0 +1,21 @@ +using Model; +using Model.Dto.Inspection; +using System.Threading.Tasks; + +namespace Service.Interface +{ + /// + /// 告警中心服务接口(全系统统一告警上报入口) + /// 其它模块(规则引擎/自动巡检/看板/数采等)只需注入本接口调用 RaiseAsync 即可产生告警, + /// 去重合并、落库、通知分发、规则计数均由告警中心内部完成,调用方无需关心 + /// + public interface IAlertCenterService + { + /// + /// 上报一条告警 + /// + /// 告警上报参数(除设备标识外均可空) + /// 返回告警消息 Id(string,雪花 Id);合并到已有告警时返回已有告警的 Id + Task> RaiseAsync(AlertRaiseDto dto); + } +} diff --git a/Service/Interface/Inspection/IAlertMessageService.cs b/Service/Interface/Inspection/IAlertMessageService.cs index a25ca23..a3bdc6e 100644 --- a/Service/Interface/Inspection/IAlertMessageService.cs +++ b/Service/Interface/Inspection/IAlertMessageService.cs @@ -1,10 +1,79 @@ +using Model; +using Model.Dto.Inspection; +using Model.Entity.Inspection; +using Model.Mapper; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + namespace Service.Interface { /// - /// 消息告警 服务接口 + /// 消息告警 服务接口(告警列表/闭环操作/统计分析) /// public interface IAlertMessageService { - // TODO: 定义 消息告警 相关方法 + /// + /// 分页查询告警(支持级别/状态/类型/来源/设备/关键字/时间范围筛选) + /// + Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, + AlertLevelEnum? level, AlertStatusEnum? status, AlertTypeEnum? type, AlertSourceEnum? source, + string? deviceCode, string? keyword, DateTime? startDate, DateTime? endDate); + + /// + /// 告警详情 + /// + Task> GetByIdAsync(long id); + + /// + /// 确认告警 + /// + Task> AcknowledgeAsync(long id, AlertOperateDto dto); + + /// + /// 处理告警 + /// + Task> HandleAsync(long id, AlertOperateDto dto); + + /// + /// 关闭告警 + /// + Task> CloseAsync(long id, AlertOperateDto dto); + + /// + /// 忽略告警 + /// + Task> IgnoreAsync(long id, AlertOperateDto dto); + + /// + /// 转工单(一期仅变更状态并记录 WorkOrderId,工单模块二期对接) + /// + Task> ToWorkOrderAsync(long id, AlertOperateDto dto); + + /// + /// 告警概览(列表页顶部卡片) + /// + Task> GetOverviewAsync(); + + /// + /// 趋势统计(granularity:day/week/month) + /// + Task>> GetTrendAsync(string granularity, DateTime? startDate, DateTime? endDate); + + /// + /// 分布统计(dimension:device/type/level/source) + /// + Task>> GetDistributionAsync(string dimension, DateTime? startDate, DateTime? endDate); + + /// + /// 高频告警 TOP 排名 + /// + Task>> GetTopAsync(int topN, DateTime? startDate, DateTime? endDate); + + /// + /// 处理及时率统计 + /// + Task> GetTimelinessAsync(DateTime? startDate, DateTime? endDate); } } diff --git a/Service/Interface/Inspection/IAlertNotifier.cs b/Service/Interface/Inspection/IAlertNotifier.cs new file mode 100644 index 0000000..a282bbd --- /dev/null +++ b/Service/Interface/Inspection/IAlertNotifier.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Service.Interface +{ + /// + /// 通知消息(Notifier 统一入参:与具体渠道报文格式解耦) + /// + public class AlertNotifyMessage + { + /// + /// 消息标题 + /// + public string Title { get; set; } = "设备告警"; + + /// + /// 消息正文(markdown 语法,各 Notifier 按需降级为纯文本) + /// + public string Content { get; set; } = string.Empty; + + /// + /// @ 手机号列表(钉钉/企微支持;飞书文本追加展示) + /// + public List AtMobiles { get; set; } = new(); + } + + /// + /// 通知发送结果 + /// + public class NotifyResult + { + /// + /// 是否成功 + /// + public bool Success { get; set; } + + /// + /// 错误信息(失败时填写,含渠道响应原文摘要) + /// + public string? ErrorMsg { get; set; } + + public static NotifyResult Ok() => new() { Success = true }; + public static NotifyResult Fail(string msg) => new() { Success = false, ErrorMsg = msg }; + } + + /// + /// 告警通知渠道发送器抽象(一期:飞书/钉钉/企微 Webhook 机器人) + /// 扩展新渠道(如短信网关)只需实现本接口并在 DependencyInjection.AddAlertNotification 注册,调用方零改动 + /// + public interface IAlertNotifier + { + /// + /// 本发送器支持的渠道类型 + /// + Model.Entity.Inspection.NotifyChannelTypeEnum ChannelType { get; } + + /// + /// 发送消息 + /// + /// 统一通知消息 + /// 渠道 Webhook 地址 + /// 加签密钥(可空:未开启加签) + /// 取消令牌 + Task SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default); + } +} diff --git a/Service/Interface/Inspection/IAlertNotifyService.cs b/Service/Interface/Inspection/IAlertNotifyService.cs new file mode 100644 index 0000000..32a57f3 --- /dev/null +++ b/Service/Interface/Inspection/IAlertNotifyService.cs @@ -0,0 +1,95 @@ +using Model; +using Model.Dto.Inspection; +using Model.Entity.Inspection; +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Service.Interface +{ + /// + /// 告警通知配置服务接口(渠道/通知规则/推送日志/值班排班/跨网发件箱) + /// + public interface IAlertNotifyService + { + #region 通知渠道 + /// 渠道列表 + Task>> GetChannelsAsync(); + + /// 新增渠道 + Task> AddChannelAsync(AlertNotifyChannelDto dto); + + /// 修改渠道(Secret 传空表示保持原值不覆盖) + Task> UpdateChannelAsync(AlertNotifyChannelDto dto); + + /// 删除渠道(软删除) + Task> DeleteChannelAsync(long id); + + /// 渠道测试推送(发送一条测试消息验证 Webhook 配置,结果写入推送日志) + Task> TestSendAsync(long channelId); + #endregion + + #region 通知规则 + /// 规则列表 + Task>> GetRulesAsync(); + + /// 新增规则 + Task> AddRuleAsync(AlertNotifyRuleDto dto); + + /// 修改规则 + Task> UpdateRuleAsync(AlertNotifyRuleDto dto); + + /// 删除规则(软删除) + Task> DeleteRuleAsync(long id); + + /// 启用/停用规则 + Task> SetRuleEnabledAsync(long id, bool enabled); + #endregion + + #region 推送日志 + /// 推送日志分页(可按成功状态/渠道筛选) + Task>> GetLogsPagedAsync(int pageIndex, int pageSize, RefAsync total, + bool? success, long channelId = 0); + #endregion + + #region 值班排班 + /// 值班排班列表(按日期范围) + Task>> GetDutiesAsync(DateTime? startDate, DateTime? endDate); + + /// 新增排班 + Task> AddDutyAsync(DutyRosterDto dto); + + /// 修改排班 + Task> UpdateDutyAsync(DutyRosterDto dto); + + /// 删除排班(软删除) + Task> DeleteDutyAsync(long id); + + /// 查询指定日期的值班人(通知规则关联值班人时使用) + Task GetDutyByDateAsync(DateTime date); + #endregion + + #region 跨网发件箱(AlertBridge 对接) + /// + /// 写入推送日志(供 AlertNotifyWorker 直连推送后复用) + /// + Task WriteLogAsync(long alertId, long channelId, string content, bool success, string? errorMsg, int retryCount); + + /// + /// 写入跨网发件箱(供 AlertNotifyWorker 的 Outbox 模式复用) + /// + Task EnqueueOutboxAsync(long alertId, long channelId, AlertNotifyMessage message, List atMobiles); + + /// + /// 拉取待推送发件箱(原子标记为已拉取,防止多实例重复推送) + /// + Task>> PullOutboxAsync(int batch); + + /// + /// 回写发件箱推送结果(成功/失败,失败累加重试次数并写推送日志) + /// + Task> ReportOutboxResultAsync(long id, OutboxResultDto dto); + #endregion + } +} diff --git a/Service/Interface/Inspection/IFeishuContactService.cs b/Service/Interface/Inspection/IFeishuContactService.cs new file mode 100644 index 0000000..4716bb4 --- /dev/null +++ b/Service/Interface/Inspection/IFeishuContactService.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace Service.Interface +{ + /// + /// 飞书通讯录服务:手机号 → open_id + /// 飞书 at 标签只认 open_id,不认手机号;群机器人 webhook 自身无查询能力,需自建应用凭证(FeishuConfig)。 + /// + public interface IFeishuContactService + { + /// + /// 用手机号换取飞书 open_id;未配置应用凭证、查无此人或无权限时返回 null + /// + /// 手机号(中国大陆 11 位) + Task GetOpenIdByMobileAsync(string mobile); + } +} diff --git a/Service/Service.csproj b/Service/Service.csproj index f0af90e..b0527b0 100644 --- a/Service/Service.csproj +++ b/Service/Service.csproj @@ -8,6 +8,8 @@ + +