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/Config/IotDeviceController.cs b/IOT_API/Controllers/Config/IotDeviceController.cs
index d9027f2..0c64249 100644
--- a/IOT_API/Controllers/Config/IotDeviceController.cs
+++ b/IOT_API/Controllers/Config/IotDeviceController.cs
@@ -15,23 +15,26 @@ namespace WebAPI.Controllers
public class IotDeviceController : ControllerBase
{
private readonly IDeviceService _deviceService;
+ private readonly IDeviceCommandService _deviceCommandService;
- public IotDeviceController(IDeviceService deviceService)
+ public IotDeviceController(IDeviceService deviceService, IDeviceCommandService deviceCommandService)
{
_deviceService = deviceService;
+ _deviceCommandService = deviceCommandService;
}
///
- /// 设备列表(分页查询,支持关键字搜索)
+ /// 设备列表(分页查询,支持关键字搜索,可按所属产品筛选)
///
/// 页码(从1开始,默认1)
/// 每页数量(默认10)
/// 关键字(模糊匹配设备编号/名称/类型)
+ /// 所属产品Id(0 表示不过滤)
[HttpGet("list")]
- public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null)
+ public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long productId = 0)
{
RefAsync total = 0;
- var result = await _deviceService.GetPagedAsync(pageIndex, pageSize, total, keyword);
+ var result = await _deviceService.GetPagedAsync(pageIndex, pageSize, total, keyword, productId > 0 ? productId : null);
Response.Headers["X-Total-Count"] = total.Value.ToString();
return result.IsSuccess
? Ok(Result>.Success(result.Data))
@@ -92,5 +95,19 @@ namespace WebAPI.Controllers
? Ok(Result>.Success(result.Data))
: Ok(Result>.Error(result.Msg));
}
+
+ ///
+ /// 按物模型可写点向设备下发指令
+ ///
+ /// 设备主键 Id
+ /// 指令请求(PointId 点Id + Value 工程值 + IsSimulated 是否模拟)
+ [HttpPost("{id}/command")]
+ public async Task SendCommand(long id, [FromBody] DeviceCommandDto dto)
+ {
+ if (dto == null)
+ return Ok(Result.Error("请求体不能为空"));
+ dto.Id = id.ToString();
+ return Ok(await _deviceCommandService.SendCommandAsync(dto));
+ }
}
}
diff --git a/IOT_API/Controllers/Config/ProductController.cs b/IOT_API/Controllers/Config/ProductController.cs
index 1aed918..533a633 100644
--- a/IOT_API/Controllers/Config/ProductController.cs
+++ b/IOT_API/Controllers/Config/ProductController.cs
@@ -1,14 +1,128 @@
using Microsoft.AspNetCore.Mvc;
+using Model;
+using Model.Dto.Config;
+using Service.Interface.Config;
+using SqlSugar;
+using System.Collections.Generic;
+using System.Threading.Tasks;
namespace WebAPI.Controllers
{
///
- /// 产品管理
+ /// 产品管理(含产品分类)
///
[ApiController]
[Route("api/config/product")]
public class ProductController : ControllerBase
{
- // TODO: 实现 产品管理 相关接口
+ private readonly IProductService _productService;
+
+ public ProductController(IProductService productService)
+ {
+ _productService = productService;
+ }
+
+ // ===================== 产品分类 =====================
+
+ ///
+ /// 产品分类列表
+ ///
+ [HttpGet("category/list")]
+ public async Task GetCategories()
+ {
+ return Ok(await _productService.GetCategoriesAsync());
+ }
+
+ ///
+ /// 新增产品分类
+ ///
+ [HttpPost("category/add")]
+ public async Task AddCategory([FromBody] ProductCategoryDto dto)
+ {
+ return Ok(await _productService.AddCategoryAsync(dto));
+ }
+
+ ///
+ /// 修改产品分类
+ ///
+ [HttpPut("category/update")]
+ public async Task UpdateCategory([FromBody] ProductCategoryDto dto)
+ {
+ return Ok(await _productService.UpdateCategoryAsync(dto));
+ }
+
+ ///
+ /// 删除产品分类(软删除)
+ ///
+ [HttpDelete("category/{id}")]
+ public async Task DeleteCategory(long id)
+ {
+ return Ok(await _productService.DeleteCategoryAsync(id));
+ }
+
+ // ===================== 产品 =====================
+
+ ///
+ /// 产品下拉选项(设备选择所属产品)
+ ///
+ [HttpGet("options")]
+ public async Task GetOptions()
+ {
+ return Ok(await _productService.GetOptionsAsync());
+ }
+
+ ///
+ /// 产品列表(分页)
+ ///
+ /// 页码(从1开始,默认1)
+ /// 每页数量(默认10)
+ /// 关键字(模糊匹配型号/名称)
+ /// 分类Id(0 表示不过滤)
+ [HttpGet("list")]
+ public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, long categoryId = 0)
+ {
+ RefAsync total = 0;
+ var result = await _productService.GetPagedAsync(pageIndex, pageSize, total, keyword, categoryId);
+ Response.Headers["X-Total-Count"] = total.Value.ToString();
+ return result.IsSuccess
+ ? Ok(Result>.Success(result.Data))
+ : Ok(Result>.Error(result.Msg));
+ }
+
+ ///
+ /// 产品详情
+ ///
+ [HttpGet("{id}")]
+ public async Task GetById(long id)
+ {
+ return Ok(await _productService.GetByIdAsync(id));
+ }
+
+ ///
+ /// 新增产品
+ ///
+ [HttpPost]
+ public async Task Add([FromBody] ProductDto dto)
+ {
+ return Ok(await _productService.AddAsync(dto));
+ }
+
+ ///
+ /// 修改产品
+ ///
+ [HttpPut]
+ public async Task Update([FromBody] ProductDto dto)
+ {
+ return Ok(await _productService.UpdateAsync(dto));
+ }
+
+ ///
+ /// 删除产品(软删除)
+ ///
+ [HttpDelete("{id}")]
+ public async Task Delete(long id)
+ {
+ return Ok(await _productService.DeleteAsync(id));
+ }
}
}
diff --git a/IOT_API/Controllers/Config/ThingPointController.cs b/IOT_API/Controllers/Config/ThingPointController.cs
new file mode 100644
index 0000000..0845864
--- /dev/null
+++ b/IOT_API/Controllers/Config/ThingPointController.cs
@@ -0,0 +1,72 @@
+using Microsoft.AspNetCore.Mvc;
+using Model;
+using Model.Dto.Config;
+using Service.Interface.Config;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace WebAPI.Controllers
+{
+ ///
+ /// 物模型点(产品/设备通用,按 ownerType+ownerId 归属)
+ ///
+ [ApiController]
+ [Route("api/config/thing-point")]
+ public class ThingPointController : ControllerBase
+ {
+ private readonly IThingPointService _thingPointService;
+
+ public ThingPointController(IThingPointService thingPointService)
+ {
+ _thingPointService = thingPointService;
+ }
+
+ ///
+ /// 点列表
+ ///
+ /// 归属类型(1=产品 2=设备)
+ /// 归属对象Id
+ /// 关键字(点编码/名称)
+ [HttpGet("list")]
+ public async Task GetList(int ownerType, long ownerId, string? keyword = null)
+ {
+ return Ok(await _thingPointService.GetListAsync(ownerType, ownerId, keyword));
+ }
+
+ ///
+ /// 点详情
+ ///
+ [HttpGet("{id}")]
+ public async Task GetById(long id)
+ {
+ return Ok(await _thingPointService.GetByIdAsync(id));
+ }
+
+ ///
+ /// 新增点
+ ///
+ [HttpPost]
+ public async Task Add([FromBody] ThingModelPointDto dto)
+ {
+ return Ok(await _thingPointService.AddAsync(dto));
+ }
+
+ ///
+ /// 修改点
+ ///
+ [HttpPut]
+ public async Task Update([FromBody] ThingModelPointDto dto)
+ {
+ return Ok(await _thingPointService.UpdateAsync(dto));
+ }
+
+ ///
+ /// 删除点(软删除)
+ ///
+ [HttpDelete("{id}")]
+ public async Task Delete(long id)
+ {
+ return Ok(await _thingPointService.DeleteAsync(id));
+ }
+ }
+}
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/Controllers/Inspection/AlertRuleController.cs b/IOT_API/Controllers/Inspection/AlertRuleController.cs
index 2f12f6c..0c4fb27 100644
--- a/IOT_API/Controllers/Inspection/AlertRuleController.cs
+++ b/IOT_API/Controllers/Inspection/AlertRuleController.cs
@@ -28,6 +28,18 @@ namespace WebAPI.Controllers
return Ok(await _alertRuleService.GetListAsync());
}
+ ///
+ /// 获取某归属对象(产品/设备)下的规则列表
+ ///
+ /// 归属类型(1=产品 2=设备)
+ /// 归属对象Id
+ /// 可选:绑定点编码过滤
+ [HttpGet("owner")]
+ public async Task GetOwnerRules(int ownerType, long ownerId, string? pointCode = null)
+ {
+ return Ok(await _alertRuleService.GetOwnerRulesAsync(ownerType, ownerId, pointCode));
+ }
+
///
/// 新增告警规则(前端传 DTO)
///
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/Config/DeviceCommandDto.cs b/Model/Dto/Config/DeviceCommandDto.cs
new file mode 100644
index 0000000..da650fb
--- /dev/null
+++ b/Model/Dto/Config/DeviceCommandDto.cs
@@ -0,0 +1,28 @@
+namespace Model.Dto.Config
+{
+ ///
+ /// 设备指令下发请求 DTO
+ ///
+ public class DeviceCommandDto
+ {
+ ///
+ /// 设备主键 Id(string)
+ ///
+ public string Id { get; set; }
+
+ ///
+ /// 物模型点 Id(string,决定写哪个寄存器/线圈)
+ ///
+ public string PointId { get; set; }
+
+ ///
+ /// 下发的工程值
+ ///
+ public double Value { get; set; }
+
+ ///
+ /// 是否仅记录(模拟/离线调试:不实际发网络报文)
+ ///
+ public bool IsSimulated { get; set; }
+ }
+}
diff --git a/Model/Dto/Config/IotDeviceDto.cs b/Model/Dto/Config/IotDeviceDto.cs
index 6ce12a1..0bdc35f 100644
--- a/Model/Dto/Config/IotDeviceDto.cs
+++ b/Model/Dto/Config/IotDeviceDto.cs
@@ -19,6 +19,12 @@ namespace Model.Dto.Config
/// 设备类型(型号,对应 DeviceCommand 驱动类)
public string DeviceType { get; set; }
+ /// 所属产品Id(string,0=未分类)
+ public string ProductId { get; set; }
+
+ /// 所属产品型号/名称(只读展示)
+ public string? ProductName { get; set; }
+
/// 所属网关Code
public string GatewayCode { get; set; }
diff --git a/Model/Dto/Config/ProductCategoryDto.cs b/Model/Dto/Config/ProductCategoryDto.cs
new file mode 100644
index 0000000..ca74376
--- /dev/null
+++ b/Model/Dto/Config/ProductCategoryDto.cs
@@ -0,0 +1,26 @@
+namespace Model.Dto.Config
+{
+ ///
+ /// 产品分类 DTO(Id 由 long 改为 string,避免前端精度丢失)
+ ///
+ public class ProductCategoryDto
+ {
+ /// 主键 Id(long → string)
+ public string Id { get; set; }
+
+ /// 分类编码
+ public string Code { get; set; }
+
+ /// 分类名称
+ public string? Name { get; set; }
+
+ /// 排序号
+ public int Sort { get; set; }
+
+ /// 备注
+ public string? Remark { get; set; }
+
+ /// 创建时间
+ public DateTime? CreateTime { get; set; }
+ }
+}
diff --git a/Model/Dto/Config/ProductDto.cs b/Model/Dto/Config/ProductDto.cs
new file mode 100644
index 0000000..58809b7
--- /dev/null
+++ b/Model/Dto/Config/ProductDto.cs
@@ -0,0 +1,46 @@
+using Model.Entity.Config;
+
+namespace Model.Dto.Config
+{
+ ///
+ /// 产品 DTO(Id 由 long 改为 string,避免前端精度丢失)
+ ///
+ public class ProductDto
+ {
+ /// 主键 Id(long → string)
+ public string Id { get; set; }
+
+ /// 型号(唯一)
+ public string Model { get; set; }
+
+ /// 产品名称
+ public string? Name { get; set; }
+
+ /// 产品分类Id
+ public string CategoryId { get; set; }
+
+ /// 分类名称(只读展示)
+ public string? CategoryName { get; set; }
+
+ /// 制造商
+ public string? Manufacturer { get; set; }
+
+ /// 产品描述
+ public string? Description { get; set; }
+
+ /// 通讯协议
+ public IotDeviceProtocolEnum ProtocolType { get; set; }
+
+ /// 消息模型
+ public string? MessageModel { get; set; }
+
+ /// 是否启用
+ public bool IsEnabled { get; set; } = true;
+
+ /// 备注
+ public string? Remark { get; set; }
+
+ /// 创建时间
+ public DateTime? CreateTime { get; set; }
+ }
+}
diff --git a/Model/Dto/Config/ProductOptionDto.cs b/Model/Dto/Config/ProductOptionDto.cs
new file mode 100644
index 0000000..ec31ec3
--- /dev/null
+++ b/Model/Dto/Config/ProductOptionDto.cs
@@ -0,0 +1,17 @@
+namespace Model.Dto.Config
+{
+ ///
+ /// 产品下拉选项 DTO(设备选择"所属产品"、筛选下拉用)
+ ///
+ public class ProductOptionDto
+ {
+ /// 主键 Id(string)
+ public string Id { get; set; }
+
+ /// 型号
+ public string Model { get; set; }
+
+ /// 产品名称
+ public string? Name { get; set; }
+ }
+}
diff --git a/Model/Dto/Config/ThingModelPointDto.cs b/Model/Dto/Config/ThingModelPointDto.cs
new file mode 100644
index 0000000..c704aae
--- /dev/null
+++ b/Model/Dto/Config/ThingModelPointDto.cs
@@ -0,0 +1,58 @@
+using Model.Entity.Config;
+
+namespace Model.Dto.Config
+{
+ ///
+ /// 物模型点 DTO(Id 由 long 改为 string,避免前端精度丢失)
+ ///
+ public class ThingModelPointDto
+ {
+ /// 主键 Id(long → string)
+ public string Id { get; set; }
+
+ /// 归属类型(1=产品 2=设备)
+ public ThingOwnerTypeEnum OwnerType { get; set; }
+
+ /// 归属对象Id(string)
+ public string OwnerId { get; set; }
+
+ /// 点编码
+ public string Code { get; set; }
+
+ /// 点名称
+ public string? Name { get; set; }
+
+ /// 寄存器类型
+ public ThingRegisterTypeEnum RegisterType { get; set; }
+
+ /// 读写权限
+ public ThingRwEnum Rw { get; set; }
+
+ /// 数据类型
+ public ThingDataTypeEnum DataType { get; set; }
+
+ /// 寄存器起始地址
+ public ushort Address { get; set; }
+
+ /// 换算系数
+ public double Scale { get; set; } = 1;
+
+ /// 偏移量
+ public double Offset { get; set; }
+
+ /// 单位
+ public string? Unit { get; set; }
+
+ /// 是否启用
+ public bool Enabled { get; set; } = true;
+
+ /// 排序号
+ public int Sort { get; set; }
+
+ /// 备注
+ public string? Remark { get; set; }
+
+ /// 创建时间
+ public DateTime? CreateTime { get; set; }
+ }
+}
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/AlertRuleDto.cs b/Model/Dto/Inspection/AlertRuleDto.cs
index e966545..347b26f 100644
--- a/Model/Dto/Inspection/AlertRuleDto.cs
+++ b/Model/Dto/Inspection/AlertRuleDto.cs
@@ -8,6 +8,15 @@ namespace Model.Dto.Inspection
/// 主键 Id(long → string)
public string Id { get; set; }
+ /// 归属类型(0=全局;1=产品 2=设备)
+ public byte OwnerType { get; set; }
+
+ /// 归属对象Id(0 表示全局)
+ public long OwnerId { get; set; }
+
+ /// 绑定点编码(空表示整产品/设备级规则)
+ public string PointCode { get; set; }
+
/// 删除状态(0、未删除;1、已删除)
public byte IsDel { 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/Config/IotDeviceEntity.cs b/Model/Entity/Config/IotDeviceEntity.cs
index db7b14e..ac979ae 100644
--- a/Model/Entity/Config/IotDeviceEntity.cs
+++ b/Model/Entity/Config/IotDeviceEntity.cs
@@ -27,6 +27,12 @@ namespace Model.Entity.Config
[SugarColumn(ColumnDescription = "设备类型", Length = 64)]
public string DeviceType { get; set; }
+ ///
+ /// 所属产品Id(关联 ProductEntity.Id,仅用于归类,0=未分类)
+ ///
+ [SugarColumn(ColumnDescription = "所属产品Id", DefaultValue = "0")]
+ public long ProductId { get; set; }
+
///
/// 所属网关Code(对应网关实体/通道的标识,连接时据此刻查找 IP/端口/串口)
///
diff --git a/Model/Entity/Config/ProductCategoryEntity.cs b/Model/Entity/Config/ProductCategoryEntity.cs
new file mode 100644
index 0000000..0dc0deb
--- /dev/null
+++ b/Model/Entity/Config/ProductCategoryEntity.cs
@@ -0,0 +1,34 @@
+using SqlSugar;
+
+namespace Model.Entity.Config
+{
+ ///
+ /// 产品分类(字典:维护产品分类,新增产品时可设置分类)
+ ///
+ public class ProductCategoryEntity : BaseEntity
+ {
+ ///
+ /// 分类编码
+ ///
+ [SugarColumn(ColumnDescription = "分类编码", Length = 50)]
+ public string Code { get; set; }
+
+ ///
+ /// 分类名称
+ ///
+ [SugarColumn(ColumnDescription = "分类名称", Length = 100, IsNullable = true)]
+ public string? Name { get; set; }
+
+ ///
+ /// 排序号
+ ///
+ [SugarColumn(ColumnDescription = "排序号")]
+ public int Sort { get; set; }
+
+ ///
+ /// 备注
+ ///
+ [SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
+ public string? Remark { get; set; }
+ }
+}
diff --git a/Model/Entity/Config/ProductEntity.cs b/Model/Entity/Config/ProductEntity.cs
new file mode 100644
index 0000000..a9e0474
--- /dev/null
+++ b/Model/Entity/Config/ProductEntity.cs
@@ -0,0 +1,64 @@
+using SqlSugar;
+
+namespace Model.Entity.Config
+{
+ ///
+ /// 产品(按型号区分;产品给设备归类,产品的物模型/告警等配置与设备相互独立)
+ ///
+ public class ProductEntity : BaseEntity
+ {
+ ///
+ /// 型号(唯一,如 GT-100)
+ ///
+ [SugarColumn(ColumnDescription = "型号", Length = 100)]
+ public string Model { get; set; }
+
+ ///
+ /// 产品名称
+ ///
+ [SugarColumn(ColumnDescription = "产品名称", Length = 100, IsNullable = true)]
+ public string? Name { get; set; }
+
+ ///
+ /// 产品分类Id(关联 ProductCategoryEntity.Id,0 表示未分类)
+ ///
+ [SugarColumn(ColumnDescription = "产品分类Id")]
+ public long CategoryId { get; set; }
+
+ ///
+ /// 制造商
+ ///
+ [SugarColumn(ColumnDescription = "制造商", Length = 100, IsNullable = true)]
+ public string? Manufacturer { get; set; }
+
+ ///
+ /// 产品描述
+ ///
+ [SugarColumn(ColumnDescription = "产品描述", Length = 500, IsNullable = true)]
+ public string? Description { get; set; }
+
+ ///
+ /// 通讯协议(复用 IotDeviceProtocolEnum:决定 DeviceCommand 驱动分支)
+ ///
+ [SugarColumn(ColumnDescription = "通讯协议")]
+ public IotDeviceProtocolEnum ProtocolType { get; set; } = IotDeviceProtocolEnum.ModbusTcp;
+
+ ///
+ /// 消息模型(消息协议/报文格式描述,如 JSON / 透传)
+ ///
+ [SugarColumn(ColumnDescription = "消息模型", Length = 64, IsNullable = true)]
+ public string? MessageModel { get; set; }
+
+ ///
+ /// 是否启用
+ ///
+ [SugarColumn(ColumnDescription = "是否启用")]
+ public bool IsEnabled { get; set; } = true;
+
+ ///
+ /// 备注
+ ///
+ [SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
+ public string? Remark { get; set; }
+ }
+}
diff --git a/Model/Entity/Config/ThingEnums.cs b/Model/Entity/Config/ThingEnums.cs
new file mode 100644
index 0000000..7d4b929
--- /dev/null
+++ b/Model/Entity/Config/ThingEnums.cs
@@ -0,0 +1,91 @@
+namespace Model.Entity.Config
+{
+ ///
+ /// 物模型归属类型(物模型点表 OwnerType:点挂在哪一类对象下)
+ ///
+ public enum ThingOwnerTypeEnum
+ {
+ ///
+ /// 产品(型号)
+ ///
+ Product = 1,
+
+ ///
+ /// 设备(实例)
+ ///
+ Device = 2
+ }
+
+ ///
+ /// 点读写权限(决定该点是否可用于下发指令)
+ ///
+ public enum ThingRwEnum
+ {
+ ///
+ /// 只读
+ ///
+ ReadOnly = 1,
+
+ ///
+ /// 只写
+ ///
+ WriteOnly = 2,
+
+ ///
+ /// 读写
+ ///
+ ReadWrite = 3
+ }
+
+ ///
+ /// 寄存器类型
+ ///
+ public enum ThingRegisterTypeEnum
+ {
+ ///
+ /// 线圈(读/写 bool)
+ ///
+ Coil = 1,
+
+ ///
+ /// 保持寄存器(读/写)
+ ///
+ HoldingRegister = 2,
+
+ ///
+ /// 输入寄存器(只读,如 UMC1300 的温度 PV)
+ ///
+ InputRegister = 3
+ }
+
+ ///
+ /// 数据类型
+ ///
+ public enum ThingDataTypeEnum
+ {
+ ///
+ /// 布尔
+ ///
+ Bool = 1,
+
+ ///
+ /// 16位有符号整数
+ ///
+ Int16 = 2,
+
+ ///
+ /// 16位无符号整数
+ ///
+ UInt16 = 3,
+
+ ///
+ /// 32位整数(占2寄存器,暂不支持下发)
+ ///
+ Int32 = 4,
+
+ ///
+ /// 32位浮点(占2寄存器,暂不支持下发)
+ ///
+ Float = 5
+ }
+}
diff --git a/Model/Entity/Config/ThingModelPointEntity.cs b/Model/Entity/Config/ThingModelPointEntity.cs
new file mode 100644
index 0000000..3aecfda
--- /dev/null
+++ b/Model/Entity/Config/ThingModelPointEntity.cs
@@ -0,0 +1,95 @@
+using SqlSugar;
+
+namespace Model.Entity.Config
+{
+ ///
+ /// 物模型点(属性/指令通用表;通过 OwnerType+OwnerId 归属产品或设备)
+ /// 一条点 = 一个寄存器/线圈映射;可写点即"下发指令"的入口
+ ///
+ public class ThingModelPointEntity : BaseEntity
+ {
+ ///
+ /// 归属类型(1=产品 2=设备)
+ ///
+ [SugarColumn(ColumnDescription = "归属类型")]
+ public ThingOwnerTypeEnum OwnerType { get; set; }
+
+ ///
+ /// 归属对象Id(ProductEntity.Id / IotDeviceEntity.Id)
+ ///
+ [SugarColumn(ColumnDescription = "归属对象Id")]
+ public long OwnerId { get; set; }
+
+ ///
+ /// 点编码(同一归属内唯一,如 TempPV)
+ ///
+ [SugarColumn(ColumnDescription = "点编码", Length = 64)]
+ public string Code { get; set; }
+
+ ///
+ /// 点名称
+ ///
+ [SugarColumn(ColumnDescription = "点名称", Length = 100, IsNullable = true)]
+ public string? Name { get; set; }
+
+ ///
+ /// 寄存器类型(线圈/保持寄存器/输入寄存器)
+ ///
+ [SugarColumn(ColumnDescription = "寄存器类型")]
+ public ThingRegisterTypeEnum RegisterType { get; set; }
+
+ ///
+ /// 读写权限
+ ///
+ [SugarColumn(ColumnDescription = "读写权限")]
+ public ThingRwEnum Rw { get; set; }
+
+ ///
+ /// 数据类型
+ ///
+ [SugarColumn(ColumnDescription = "数据类型")]
+ public ThingDataTypeEnum DataType { get; set; }
+
+ ///
+ /// 寄存器起始地址
+ ///
+ [SugarColumn(ColumnDescription = "寄存器起始地址")]
+ public ushort Address { get; set; }
+
+ ///
+ /// 换算系数(工程值 = 寄存器原始值 / Scale + Offset,对应驱动内 SCALE)
+ ///
+ [SugarColumn(ColumnDescription = "换算系数", DecimalDigits = 4)]
+ public double Scale { get; set; } = 1;
+
+ ///
+ /// 偏移量
+ ///
+ [SugarColumn(ColumnDescription = "偏移量", DecimalDigits = 4)]
+ public double Offset { get; set; }
+
+ ///
+ /// 单位
+ ///
+ [SugarColumn(ColumnDescription = "单位", Length = 16, IsNullable = true)]
+ public string? Unit { get; set; }
+
+ ///
+ /// 是否启用
+ ///
+ [SugarColumn(ColumnDescription = "是否启用")]
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// 排序号
+ ///
+ [SugarColumn(ColumnDescription = "排序号")]
+ public int Sort { get; set; }
+
+ ///
+ /// 备注
+ ///
+ [SugarColumn(ColumnDescription = "备注", Length = 500, IsNullable = true)]
+ public string? Remark { 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/AlertRuleEntity.cs b/Model/Entity/Inspection/AlertRuleEntity.cs
index fa56735..7e3756a 100644
--- a/Model/Entity/Inspection/AlertRuleEntity.cs
+++ b/Model/Entity/Inspection/AlertRuleEntity.cs
@@ -8,6 +8,24 @@ namespace Model.Entity.Inspection
///
public class AlertRuleEntity : BaseEntity
{
+ ///
+ /// 归属类型(0=全局/旧数据;1=产品 2=设备)
+ ///
+ [SugarColumn(ColumnDescription = "归属类型", DefaultValue = "0")]
+ public byte OwnerType { get; set; }
+
+ ///
+ /// 归属对象Id(ProductEntity.Id / IotDeviceEntity.Id,0 表示全局)
+ ///
+ [SugarColumn(ColumnDescription = "归属对象Id", DefaultValue = "0")]
+ public long OwnerId { get; set; }
+
+ ///
+ /// 绑定点编码(关联物模型点 Code;为空表示整产品/设备级规则)
+ ///
+ [SugarColumn(ColumnDescription = "绑定点编码", Length = 64, IsNullable = true)]
+ public string? PointCode { 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..88d2b1c 100644
--- a/Model/Mapper/EntityMapper.cs
+++ b/Model/Mapper/EntityMapper.cs
@@ -255,6 +255,9 @@ namespace Model.Mapper
return new AlertRuleDto
{
Id = entity.Id.ToString(),
+ OwnerType = entity.OwnerType,
+ OwnerId = entity.OwnerId,
+ PointCode = entity.PointCode,
IsDel = entity.IsDel,
CreateTime = entity.CreateTime,
RuleName = entity.RuleName,
@@ -352,6 +355,9 @@ namespace Model.Mapper
return new AlertRuleEntity
{
Id = ParseId(dto.Id),
+ OwnerType = dto.OwnerType,
+ OwnerId = dto.OwnerId,
+ PointCode = dto.PointCode,
IsDel = dto.IsDel,
CreateTime = dto.CreateTime,
RuleName = dto.RuleName,
@@ -403,6 +409,7 @@ namespace Model.Mapper
Code = entity.Code,
Name = entity.Name,
DeviceType = entity.DeviceType,
+ ProductId = entity.ProductId.ToString(),
GatewayCode = entity.GatewayCode,
SlaveId = entity.SlaveId,
Manufacturer = entity.Manufacturer,
@@ -437,6 +444,7 @@ namespace Model.Mapper
Code = dto.Code,
Name = dto.Name,
DeviceType = dto.DeviceType,
+ ProductId = ParseId(dto.ProductId),
GatewayCode = dto.GatewayCode,
SlaveId = dto.SlaveId,
Manufacturer = dto.Manufacturer,
@@ -476,5 +484,456 @@ namespace Model.Mapper
return entities?.Select(e => e.ToDto()).ToList() ?? new List();
}
#endregion
+
+ #region 产品分类
+ ///
+ /// ProductCategoryEntity → ProductCategoryDto
+ ///
+ public static ProductCategoryDto ToDto(this ProductCategoryEntity entity)
+ {
+ if (entity == null) return null;
+ return new ProductCategoryDto
+ {
+ Id = entity.Id.ToString(),
+ Code = entity.Code,
+ Name = entity.Name,
+ Sort = entity.Sort,
+ Remark = entity.Remark,
+ CreateTime = entity.CreateTime
+ };
+ }
+
+ ///
+ /// List<ProductCategoryEntity> → List<ProductCategoryDto>
+ ///
+ public static List ToDtoList(this List entities)
+ {
+ return entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ }
+
+ ///
+ /// ProductCategoryDto → ProductCategoryEntity(入参映射)
+ ///
+ public static ProductCategoryEntity ToEntity(this ProductCategoryDto dto)
+ {
+ if (dto == null) return null;
+ return new ProductCategoryEntity
+ {
+ Id = ParseId(dto.Id),
+ Code = dto.Code,
+ Name = dto.Name,
+ Sort = dto.Sort,
+ Remark = dto.Remark
+ };
+ }
+ #endregion
+
+ #region 产品
+ ///
+ /// ProductEntity → ProductDto
+ ///
+ public static ProductDto ToDto(this ProductEntity entity)
+ {
+ if (entity == null) return null;
+ return new ProductDto
+ {
+ Id = entity.Id.ToString(),
+ Model = entity.Model,
+ Name = entity.Name,
+ CategoryId = entity.CategoryId.ToString(),
+ Manufacturer = entity.Manufacturer,
+ Description = entity.Description,
+ ProtocolType = entity.ProtocolType,
+ MessageModel = entity.MessageModel,
+ IsEnabled = entity.IsEnabled,
+ Remark = entity.Remark,
+ CreateTime = entity.CreateTime
+ };
+ }
+
+ ///
+ /// List<ProductEntity> → List<ProductDto>
+ ///
+ public static List ToDtoList(this List entities)
+ {
+ return entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ }
+
+ ///
+ /// ProductDto → ProductEntity(入参映射,IsDel/CreateTime 等服务端管控字段不映射)
+ ///
+ public static ProductEntity ToEntity(this ProductDto dto)
+ {
+ if (dto == null) return null;
+ return new ProductEntity
+ {
+ Id = ParseId(dto.Id),
+ Model = dto.Model,
+ Name = dto.Name,
+ CategoryId = ParseId(dto.CategoryId),
+ Manufacturer = dto.Manufacturer,
+ Description = dto.Description,
+ ProtocolType = dto.ProtocolType,
+ MessageModel = dto.MessageModel,
+ IsEnabled = dto.IsEnabled,
+ Remark = dto.Remark
+ };
+ }
+
+ ///
+ /// ProductEntity → ProductOptionDto(下拉选项)
+ ///
+ public static ProductOptionDto ToOptionDto(this ProductEntity entity)
+ {
+ if (entity == null) return null;
+ return new ProductOptionDto
+ {
+ Id = entity.Id.ToString(),
+ Model = entity.Model,
+ Name = entity.Name
+ };
+ }
+
+ ///
+ /// List<ProductEntity> → List<ProductOptionDto>
+ ///
+ public static List ToOptionDtoList(this List entities)
+ {
+ return entities?.Select(e => e.ToOptionDto()).ToList() ?? new List();
+ }
+ #endregion
+
+ #region 物模型点
+ ///
+ /// ThingModelPointEntity → ThingModelPointDto
+ ///
+ public static ThingModelPointDto ToDto(this ThingModelPointEntity entity)
+ {
+ if (entity == null) return null;
+ return new ThingModelPointDto
+ {
+ Id = entity.Id.ToString(),
+ OwnerType = entity.OwnerType,
+ OwnerId = entity.OwnerId.ToString(),
+ Code = entity.Code,
+ Name = entity.Name,
+ RegisterType = entity.RegisterType,
+ Rw = entity.Rw,
+ DataType = entity.DataType,
+ Address = entity.Address,
+ Scale = entity.Scale,
+ Offset = entity.Offset,
+ Unit = entity.Unit,
+ Enabled = entity.Enabled,
+ Sort = entity.Sort,
+ Remark = entity.Remark,
+ CreateTime = entity.CreateTime
+ };
+ }
+
+ ///
+ /// List<ThingModelPointEntity> → List<ThingModelPointDto>
+ ///
+ public static List ToDtoList(this List entities)
+ {
+ return entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ }
+
+ ///
+ /// ThingModelPointDto → ThingModelPointEntity(入参映射)
+ ///
+ public static ThingModelPointEntity ToEntity(this ThingModelPointDto dto)
+ {
+ if (dto == null) return null;
+ return new ThingModelPointEntity
+ {
+ Id = ParseId(dto.Id),
+ OwnerType = dto.OwnerType,
+ OwnerId = ParseId(dto.OwnerId),
+ Code = dto.Code,
+ Name = dto.Name,
+ RegisterType = dto.RegisterType,
+ Rw = dto.Rw,
+ DataType = dto.DataType,
+ Address = dto.Address,
+ Scale = dto.Scale,
+ Offset = dto.Offset,
+ Unit = dto.Unit,
+ Enabled = dto.Enabled,
+ Sort = dto.Sort,
+ Remark = dto.Remark
+ };
+ }
+ #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/Config/DeviceCommandService.cs b/Service/Implement/Config/DeviceCommandService.cs
new file mode 100644
index 0000000..2ee1904
--- /dev/null
+++ b/Service/Implement/Config/DeviceCommandService.cs
@@ -0,0 +1,178 @@
+using DeviceCommand.Base;
+using Model;
+using Model.Dto.Config;
+using Model.Entity.Config;
+using ORM;
+using Service.Interface.Config;
+using SqlSugar;
+using System;
+using System.Net;
+using System.Threading.Tasks;
+
+namespace Service.Implement.Config
+{
+ ///
+ /// 设备指令下发 服务实现
+ /// 目前无网关运行时/采集引擎:每次调用先落一条设备日志(始终可验证),
+ /// 设备在线时才实际尝试 Modbus 写(GatewayCode 需为 "ip:port",默认 127.0.0.1:502)。
+ ///
+ public class DeviceCommandService : IDeviceCommandService
+ {
+ public async Task SendCommandAsync(DeviceCommandDto dto)
+ {
+ if (dto == null || !long.TryParse(dto.Id, out var deviceId) || deviceId <= 0)
+ return Result.Error("设备Id无效");
+ if (string.IsNullOrWhiteSpace(dto.PointId) || !long.TryParse(dto.PointId, out var pointId) || pointId <= 0)
+ return Result.Error("物模型点Id无效");
+
+ var now = DateTime.Now;
+
+ // 载入设备与点
+ var device = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == deviceId && x.IsDel == 0).FirstAsync();
+ if (device == null)
+ return Result.Error("设备不存在或已被删除");
+
+ var point = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == pointId && x.OwnerType == ThingOwnerTypeEnum.Device && x.OwnerId == deviceId && x.IsDel == 0).FirstAsync();
+ if (point == null)
+ return Result.Error("物模型点不存在或不属于该设备");
+
+ // 校验可写
+ if (point.Rw == ThingRwEnum.ReadOnly)
+ return Result.Error($"点【{point.Name ?? point.Code}】为只读,无法下发指令");
+ if (device.ProtocolType != IotDeviceProtocolEnum.ModbusTcp)
+ return Result.Error("当前仅支持 Modbus TCP 协议设备下发指令");
+
+ try
+ {
+ // 模拟模式 / 设备离线 → 只记录、不发网络报文
+ if (dto.IsSimulated || device.OnlineStatus != IotDeviceOnlineStatusEnum.Online)
+ {
+ await WriteLogAsync(device, "Warn", "指令", dto.IsSimulated
+ ? $"【模拟】点 {point.Name ?? point.Code} 下发值 {dto.Value}(未发送网络报文)"
+ : $"设备离线,指令已记录但未发送(点 {point.Name ?? point.Code} 值 {dto.Value})");
+ return dto.IsSimulated
+ ? Result.Success()
+ : Result.Error("设备离线,指令已记录但未发送");
+ }
+
+ // 实际 Modbus 写
+ var (host, port) = ParseGateway(device.GatewayCode);
+ using var modbus = new ModbusTcp();
+ modbus.ConfigureDevice(host, port, 3000, 3000);
+ bool connected = await modbus.ConnectAsync();
+ if (!connected)
+ {
+ await WriteLogAsync(device, "Error", "指令",
+ $"网关 {device.GatewayCode} 连接失败,指令未发送(点 {point.Name ?? point.Code} 值 {dto.Value})");
+ return Result.Error($"网关 {device.GatewayCode} 连接失败,指令未发送");
+ }
+
+ string desc;
+ if (point.RegisterType == ThingRegisterTypeEnum.Coil)
+ {
+ bool coil = dto.Value != 0;
+ await modbus.WriteSingleCoilAsync(device.SlaveId, point.Address, coil);
+ desc = $"写线圈 {point.Name ?? point.Code} 地址 {point.Address} = {coil}";
+ }
+ else if (point.DataType == ThingDataTypeEnum.Int32 || point.DataType == ThingDataTypeEnum.Float)
+ {
+ // 32 位类型占连续 2 个寄存器,走 FC16 连写(Int32 按有符号整数编码,Float 按 IEEE754 编码)
+ ushort[] words = ToWords(dto.Value, point);
+ await modbus.WriteMultipleRegistersAsync(device.SlaveId, point.Address, words);
+ desc = $"写双寄存器 {point.Name ?? point.Code} 地址 {point.Address}~{point.Address + 1} = [{words[0]}, {words[1]}](工程值 {dto.Value})";
+ }
+ else
+ {
+ ushort raw = ToRaw(dto.Value, point);
+ await modbus.WriteSingleRegisterAsync(device.SlaveId, point.Address, raw);
+ desc = $"写寄存器 {point.Name ?? point.Code} 地址 {point.Address} = {raw}(工程值 {dto.Value})";
+ }
+
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => new IotDeviceEntity { LastCollectTime = now, LastError = null })
+ .Where(x => x.Id == device.Id).ExecuteCommandAsync();
+
+ await WriteLogAsync(device, "Info", "指令", $"下发成功:{desc}");
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ await WriteLogAsync(device, "Error", "指令", $"下发失败:{ex.Message}");
+ return Result.Error($"指令下发异常:{ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// 工程值 → 寄存器原始值:raw = (value - Offset) / Scale,取整并夹到寄存器范围
+ /// Int16 按有符号处理(补码),其余类型按 0..65535
+ ///
+ private static ushort ToRaw(double value, ThingModelPointEntity point)
+ {
+ double raw = (value - point.Offset) / point.Scale;
+ if (point.DataType == ThingDataTypeEnum.Int16)
+ {
+ if (raw <= short.MinValue) return unchecked((ushort)short.MinValue);
+ if (raw >= short.MaxValue) return (ushort)short.MaxValue;
+ return unchecked((ushort)(short)Math.Round(raw));
+ }
+ if (raw <= 0) return 0;
+ if (raw >= 65535) return 65535;
+ return (ushort)Math.Round(raw);
+ }
+
+ ///
+ /// 工程值 → 32 位双寄存器字数组(raw32 = (value - Offset) / Scale)
+ /// Int32 按有符号整数编码,Float 按 IEEE754 编码;字序按 Modbus 常规高字在前(AB CD)
+ ///
+ private static ushort[] ToWords(double value, ThingModelPointEntity point)
+ {
+ uint bits;
+ if (point.DataType == ThingDataTypeEnum.Float)
+ {
+ bits = BitConverter.SingleToUInt32Bits((float)((value - point.Offset) / point.Scale));
+ }
+ else
+ {
+ double raw = Math.Round((value - point.Offset) / point.Scale);
+ if (raw < int.MinValue) raw = int.MinValue;
+ if (raw > int.MaxValue) raw = int.MaxValue;
+ bits = unchecked((uint)(int)raw);
+ }
+ return new[] { (ushort)(bits >> 16), (ushort)(bits & 0xFFFF) };
+ }
+
+ ///
+ /// 解析 GatewayCode 为 host:port;非法/缺失回退 127.0.0.1:502
+ ///
+ private static (string host, int port) ParseGateway(string? gatewayCode)
+ {
+ if (!string.IsNullOrWhiteSpace(gatewayCode))
+ {
+ var idx = gatewayCode.LastIndexOf(':');
+ if (idx > 0 && IPAddress.TryParse(gatewayCode[..idx], out _) && int.TryParse(gatewayCode[(idx + 1)..], out var p) && p > 0)
+ return (gatewayCode[..idx], p);
+ }
+ return ("127.0.0.1", 502);
+ }
+
+ ///
+ /// 写一条设备日志(设备独享日志,前端设备日志抽屉可见)
+ ///
+ private static async Task WriteLogAsync(IotDeviceEntity device, string level, string logType, string message)
+ {
+ var now = DateTime.Now;
+ await SqlSugarContext.DbContext.Insertable(new DeviceLogEntity
+ {
+ DeviceId = device.Id,
+ DeviceCode = device.Code,
+ Level = level,
+ LogType = logType,
+ Message = message,
+ LogTime = now,
+ CreateTime = now
+ }).ExecuteCommandAsync();
+ }
+ }
+}
diff --git a/Service/Implement/Config/DeviceService.cs b/Service/Implement/Config/DeviceService.cs
index ffdd447..f90c9bd 100644
--- a/Service/Implement/Config/DeviceService.cs
+++ b/Service/Implement/Config/DeviceService.cs
@@ -7,6 +7,7 @@ using Service.Interface.Config;
using SqlSugar;
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
namespace Service.Implement.Config
@@ -17,9 +18,9 @@ namespace Service.Implement.Config
public class DeviceService : IDeviceService
{
///
- /// 分页查询设备列表(支持关键字搜索编号/名称/类型)
+ /// 分页查询设备列表(支持关键字搜索编号/名称/类型,可按所属产品筛选)
///
- public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword)
+ public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword, long? productId = null)
{
try
{
@@ -27,10 +28,13 @@ namespace Service.Implement.Config
.Where(x => x.IsDel == 0)
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
x => x.Code.Contains(keyword!) || x.Name.Contains(keyword!) || x.DeviceType.Contains(keyword!))
+ .WhereIF(productId.HasValue && productId.Value > 0, x => x.ProductId == productId!.Value)
.OrderBy(x => x.CreateTime, OrderByType.Desc)
.ToPageListAsync(pageIndex, pageSize, total);
- return Result>.Success(list.ToDtoList());
+ var dtos = list.ToDtoList();
+ await FillProductNames(dtos);
+ return Result>.Success(dtos);
}
catch (Exception ex)
{
@@ -50,7 +54,10 @@ namespace Service.Implement.Config
.FirstAsync();
if (entity == null)
return Result.Error("设备不存在或已被删除");
- return Result.Success(entity.ToDto());
+
+ var dto = entity.ToDto();
+ await FillProductNames(new List { dto });
+ return Result.Success(dto);
}
catch (Exception ex)
{
@@ -157,5 +164,30 @@ namespace Service.Implement.Config
return Result>.Error("查询设备日志失败", ex);
}
}
+
+ ///
+ /// 给设备 DTO 填充所属产品的型号/名称(ProductName 展示用)
+ ///
+ private async Task FillProductNames(List dtos)
+ {
+ if (dtos == null || dtos.Count == 0)
+ return;
+
+ var ids = dtos.Where(x => long.TryParse(x.ProductId, out var pid) && pid > 0)
+ .Select(x => long.Parse(x.ProductId!)).Distinct().ToList();
+ if (ids.Count == 0)
+ return;
+
+ var products = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => ids.Contains(x.Id) && x.IsDel == 0)
+ .ToListAsync();
+ var map = products.ToDictionary(p => p.Id, p => p.Name ?? p.Model);
+
+ foreach (var d in dtos)
+ {
+ if (long.TryParse(d.ProductId, out var pid) && map.TryGetValue(pid, out var name))
+ d.ProductName = name;
+ }
+ }
}
}
diff --git a/Service/Implement/Config/ProductService.cs b/Service/Implement/Config/ProductService.cs
index 1254ca9..4934568 100644
--- a/Service/Implement/Config/ProductService.cs
+++ b/Service/Implement/Config/ProductService.cs
@@ -1,12 +1,255 @@
-using Service.Interface;
+using Model;
+using Model.Dto.Config;
+using Model.Entity.Config;
+using Model.Mapper;
+using ORM;
+using Service.Interface.Config;
+using SqlSugar;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
-namespace Service.Implement
+namespace Service.Implement.Config
{
///
- /// 产品管理 服务实现
+ /// 产品管理(含产品分类)服务实现
///
public class ProductService : IProductService
{
- // TODO: 实现 产品管理 相关方法
+ // ===================== 产品分类 =====================
+
+ public async Task>> GetCategoriesAsync()
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .OrderBy(x => x.Sort)
+ .ToListAsync();
+ return Result>.Success(list.ToDtoList());
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询产品分类失败", ex);
+ }
+ }
+
+ public async Task AddCategoryAsync(ProductCategoryDto dto)
+ {
+ if (dto == null || string.IsNullOrWhiteSpace(dto.Code))
+ return Result.Error("分类编码不能为空");
+
+ try
+ {
+ bool exists = await SqlSugarContext.DbContext.Queryable()
+ .AnyAsync(x => x.Code == dto.Code && x.IsDel == 0);
+ if (exists)
+ return Result.Error($"分类编码【{dto.Code}】已存在");
+
+ var entity = dto.ToEntity();
+ entity.CreateTime = DateTime.Now;
+ await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("新增产品分类失败", ex);
+ }
+ }
+
+ public async Task UpdateCategoryAsync(ProductCategoryDto dto)
+ {
+ var entity = dto?.ToEntity();
+ if (entity == null || entity.Id <= 0)
+ return Result.Error("分类Id无效");
+
+ try
+ {
+ bool exists = await SqlSugarContext.DbContext.Queryable()
+ .AnyAsync(x => x.Code == entity.Code && x.IsDel == 0 && x.Id != entity.Id);
+ if (exists)
+ return Result.Error($"分类编码【{entity.Code}】已存在");
+
+ await SqlSugarContext.DbContext.Updateable(entity)
+ .IgnoreColumns(x => new { x.CreateTime, x.IsDel })
+ .ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("修改产品分类失败", ex);
+ }
+ }
+
+ public async Task DeleteCategoryAsync(long id)
+ {
+ if (id <= 0)
+ return Result.Error("分类Id无效");
+
+ try
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDel == 1)
+ .Where(x => x.Id == id)
+ .ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("删除产品分类失败", ex);
+ }
+ }
+
+ // ===================== 产品 =====================
+
+ public async Task>> GetPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword, long categoryId)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword),
+ x => x.Model.Contains(keyword!) || (x.Name != null && x.Name.Contains(keyword!)))
+ .WhereIF(categoryId > 0, x => x.CategoryId == categoryId)
+ .OrderBy(x => x.CreateTime, OrderByType.Desc)
+ .ToPageListAsync(pageIndex, pageSize, total);
+
+ var dtos = list.ToDtoList();
+ await FillCategoryNames(dtos);
+ return Result>.Success(dtos);
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询产品列表失败", ex);
+ }
+ }
+
+ public async Task>> GetOptionsAsync()
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0 && x.IsEnabled)
+ .OrderBy(x => x.Model)
+ .ToListAsync();
+ return Result>.Success(list.ToOptionDtoList());
+ }
+ 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("产品不存在或已被删除");
+
+ var dto = entity.ToDto();
+ await FillCategoryNames(new List { dto });
+ return Result.Success(dto);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("查询产品详情失败", ex);
+ }
+ }
+
+ public async Task AddAsync(ProductDto dto)
+ {
+ if (dto == null || string.IsNullOrWhiteSpace(dto.Model))
+ return Result.Error("产品型号不能为空");
+
+ try
+ {
+ bool exists = await SqlSugarContext.DbContext.Queryable()
+ .AnyAsync(x => x.Model == dto.Model && x.IsDel == 0);
+ if (exists)
+ return Result.Error($"产品型号【{dto.Model}】已存在");
+
+ var entity = dto.ToEntity();
+ entity.CreateTime = DateTime.Now;
+ await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("新增产品失败", ex);
+ }
+ }
+
+ public async Task UpdateAsync(ProductDto dto)
+ {
+ var entity = dto?.ToEntity();
+ if (entity == null || entity.Id <= 0)
+ return Result.Error("产品Id无效");
+
+ try
+ {
+ bool exists = await SqlSugarContext.DbContext.Queryable()
+ .AnyAsync(x => x.Model == entity.Model && x.IsDel == 0 && x.Id != entity.Id);
+ if (exists)
+ return Result.Error($"产品型号【{entity.Model}】已存在");
+
+ await SqlSugarContext.DbContext.Updateable(entity)
+ .IgnoreColumns(x => new { x.CreateTime, x.IsDel })
+ .ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("修改产品失败", ex);
+ }
+ }
+
+ public async Task DeleteAsync(long id)
+ {
+ if (id <= 0)
+ return Result.Error("产品Id无效");
+
+ try
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDel == 1)
+ .Where(x => x.Id == id)
+ .ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("删除产品失败", ex);
+ }
+ }
+
+ ///
+ /// 给产品 DTO 填充分类名称(供列表/详情展示)
+ ///
+ private async Task FillCategoryNames(List dtos)
+ {
+ if (dtos == null || dtos.Count == 0)
+ return;
+
+ var ids = dtos.Where(x => x.CategoryId != null && long.TryParse(x.CategoryId, out _))
+ .Select(x => long.Parse(x.CategoryId!)).Distinct().ToList();
+ if (ids.Count == 0)
+ return;
+
+ var categories = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => ids.Contains(x.Id) && x.IsDel == 0)
+ .ToListAsync();
+ var map = categories.ToDictionary(c => c.Id, c => c.Name);
+
+ foreach (var d in dtos)
+ {
+ if (long.TryParse(d.CategoryId, out var cid) && map.TryGetValue(cid, out var name))
+ d.CategoryName = name;
+ }
+ }
}
}
diff --git a/Service/Implement/Config/ThingPointService.cs b/Service/Implement/Config/ThingPointService.cs
new file mode 100644
index 0000000..3f3d022
--- /dev/null
+++ b/Service/Implement/Config/ThingPointService.cs
@@ -0,0 +1,126 @@
+using Model;
+using Model.Dto.Config;
+using Model.Entity.Config;
+using Model.Mapper;
+using ORM;
+using Service.Interface.Config;
+using SqlSugar;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Service.Implement.Config
+{
+ ///
+ /// 物模型点 服务实现(产品/设备通用,按 OwnerType+OwnerId 归属)
+ ///
+ public class ThingPointService : IThingPointService
+ {
+ public async Task>> GetListAsync(int ownerType, long ownerId, string? keyword)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .Where(x => x.OwnerType == (ThingOwnerTypeEnum)ownerType && x.OwnerId == ownerId)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword),
+ x => x.Code.Contains(keyword!) || (x.Name != null && x.Name.Contains(keyword!)))
+ .OrderBy(x => x.Sort)
+ .ToListAsync();
+ 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);
+ }
+ }
+
+ public async Task AddAsync(ThingModelPointDto dto)
+ {
+ if (dto == null || string.IsNullOrWhiteSpace(dto.Code))
+ return Result.Error("点编码不能为空");
+ if (dto.OwnerId == null || !long.TryParse(dto.OwnerId, out var ownerId) || ownerId <= 0)
+ return Result.Error("归属对象Id无效");
+
+ try
+ {
+ bool exists = await SqlSugarContext.DbContext.Queryable()
+ .AnyAsync(x => x.OwnerType == dto.OwnerType && x.OwnerId == ownerId
+ && x.Code == dto.Code && x.IsDel == 0);
+ if (exists)
+ return Result.Error($"点编码【{dto.Code}】在该归属下已存在");
+
+ var entity = dto.ToEntity();
+ entity.CreateTime = DateTime.Now;
+ await SqlSugarContext.DbContext.Insertable(entity).ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("新增物模型点失败", ex);
+ }
+ }
+
+ public async Task UpdateAsync(ThingModelPointDto dto)
+ {
+ var entity = dto?.ToEntity();
+ if (entity == null || entity.Id <= 0)
+ return Result.Error("物模型点Id无效");
+
+ try
+ {
+ bool exists = await SqlSugarContext.DbContext.Queryable()
+ .AnyAsync(x => x.OwnerType == entity.OwnerType && x.OwnerId == entity.OwnerId
+ && x.Code == entity.Code && x.IsDel == 0 && x.Id != entity.Id);
+ if (exists)
+ return Result.Error($"点编码【{entity.Code}】在该归属下已存在");
+
+ await SqlSugarContext.DbContext.Updateable(entity)
+ .IgnoreColumns(x => new { x.CreateTime, x.IsDel, x.OwnerType, x.OwnerId })
+ .ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("修改物模型点失败", ex);
+ }
+ }
+
+ public async Task DeleteAsync(long id)
+ {
+ if (id <= 0)
+ return Result.Error("物模型点Id无效");
+
+ try
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDel == 1)
+ .Where(x => x.Id == id)
+ .ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("删除物模型点失败", ex);
+ }
+ }
+ }
+}
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