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