Files

260 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,钉钉/企微返回 errcode0 均为成功
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; }
}
}
}