260 lines
11 KiB
C#
260 lines
11 KiB
C#
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; }
|
||
}
|
||
}
|
||
}
|