搭建消息告警模块以及跨网推送服务,支持企微,飞书,钉钉通知
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
|
||||
}
|
||||
Reference in New Issue
Block a user