using Common.Notify; using Model.Dto.Inspection; using System.Text; using System.Text.Json; namespace AlertBridge { /// /// 跨网告警转发程序(部署在可同时访问"工控内网 IOT_API"与"外网 IM Webhook"的跳板机上) /// 工作方式:轮询拉取内网发件箱(GET outbox/pull)→ 按报文推送飞书/钉钉/企微 → 回写结果(POST outbox/{id}/result) /// 报文自包含 WebhookUrl/Secret/内容,本程序无需访问数据库,配置仅需 appsettings.json 三项 /// public class Program { private static string _apiBaseUrl = "http://localhost:5287"; private static int _pollSeconds = 5; private static int _batchSize = 20; public static async Task Main(string[] args) { LoadConfig(); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; Log($"AlertBridge 启动,API 地址: {_apiBaseUrl},轮询间隔: {_pollSeconds}s,批量: {_batchSize}"); Log("按 Ctrl+C 停止"); while (!cts.IsCancellationRequested) { try { await PollOnceAsync(http, cts.Token); } catch (OperationCanceledException) { break; } catch (Exception ex) { Log($"轮询异常: {ex.Message}"); } try { await Task.Delay(TimeSpan.FromSeconds(_pollSeconds), cts.Token); } catch (TaskCanceledException) { break; } } Log("AlertBridge 已停止"); } /// /// 单次轮询:拉取 → 逐条推送 → 回写 /// private static async Task PollOnceAsync(HttpClient http, CancellationToken ct) { string pullUrl = $"{_apiBaseUrl}/api/inspection/alert-notify/outbox/pull?batch={_batchSize}"; string pullJson = await http.GetStringAsync(pullUrl, ct); var pullResult = JsonSerializer.Deserialize>>(pullJson, JsonOpts); if (pullResult == null || pullResult.Code != 0 || pullResult.Data == null || pullResult.Data.Count == 0) { return; } Log($"拉取到 {pullResult.Data.Count} 条待推送消息"); foreach (var item in pullResult.Data) { bool success = false; string? error = null; try { var payload = JsonSerializer.Deserialize(item.Payload ?? "{}", JsonOpts); if (payload == null || string.IsNullOrWhiteSpace(payload.WebhookUrl)) { error = "Payload 为空或缺少 WebhookUrl"; } else { (success, error) = await SendAsync(http, payload, ct); } } catch (Exception ex) { error = NotifyError.Describe(ex); } // 回写结果 try { var resultBody = JsonSerializer.Serialize(new { Success = success, ResultMsg = error }, JsonOpts); using var content = new StringContent(resultBody, Encoding.UTF8, "application/json"); await http.PostAsync($"{_apiBaseUrl}/api/inspection/alert-notify/outbox/{item.Id}/result", content, ct); Log($"消息 {item.Id} 推送{(success ? "成功" : $"失败: {error}")},结果已回写"); } catch (Exception ex) { Log($"消息 {item.Id} 结果回写失败: {ex.Message}"); } } } /// /// 按渠道类型推送(与主站 Notifier 报文格式保持一致) /// private static async Task<(bool success, string? error)> SendAsync(HttpClient http, AlertOutboxPayload p, CancellationToken ct) { string url = p.WebhookUrl!; object body; switch (p.ChannelType) { case 1: // 飞书:post 富文本 + at 标签(user_id=open_id) 真正 @ + 可选加签(timestamp 秒 + sign) { var contentLines = new List>>(); foreach (var raw in (p.Content ?? string.Empty).Split('\n')) { var line = raw.TrimEnd('\r'); if (line.Length == 0) continue; contentLines.Add(new List> { new Dictionary { ["tag"] = "text", ["text"] = line } }); } if (p.AtMobiles is { Count: > 0 }) { var atLine = new List> { new Dictionary { ["tag"] = "text", ["text"] = "关注人: " } }; foreach (var openId in p.AtMobiles) { if (string.IsNullOrWhiteSpace(openId)) continue; atLine.Add(new Dictionary { ["tag"] = "at", ["user_id"] = openId.Trim() }); } contentLines.Add(atLine); } var feishuBody = new Dictionary { ["msg_type"] = "post", ["content"] = new Dictionary { ["post"] = new Dictionary { ["zh_cn"] = new Dictionary { ["title"] = p.Title ?? "设备告警", ["content"] = contentLines } } } }; if (!string.IsNullOrWhiteSpace(p.Secret)) { long ts = DateTimeOffset.Now.ToUnixTimeSeconds(); feishuBody["timestamp"] = ts.ToString(); feishuBody["sign"] = ImWebhookSigner.FeishuSign(ts, p.Secret); } body = feishuBody; break; } case 2: // 钉钉:markdown + at.atMobiles + URL 加签(timestamp 毫秒 + sign) { if (!string.IsNullOrWhiteSpace(p.Secret)) { long ts = DateTimeOffset.Now.ToUnixTimeMilliseconds(); url += $"{(url.Contains('?') ? "&" : "?")}timestamp={ts}&sign={ImWebhookSigner.DingTalkSign(ts, p.Secret)}"; } var md = new StringBuilder(); md.AppendLine($"### {p.Title}"); md.AppendLine(p.Content); foreach (var m in p.AtMobiles ?? new List()) md.Append($"@{m} "); body = new Dictionary { ["msgtype"] = "markdown", ["markdown"] = new Dictionary { ["title"] = p.Title ?? "设备告警", ["text"] = md.ToString() }, ["at"] = new Dictionary { ["atMobiles"] = p.AtMobiles ?? new List(), ["isAtAll"] = false } }; break; } case 3: // 企微:text + mentioned_mobile_list(无加签) { var text = new StringBuilder(); text.AppendLine(p.Title); text.AppendLine(p.Content); body = new Dictionary { ["msgtype"] = "text", ["text"] = new Dictionary { ["content"] = text.ToString(), ["mentioned_mobile_list"] = p.AtMobiles ?? new List() } }; break; } default: return (false, $"未知渠道类型: {p.ChannelType}"); } string json = JsonSerializer.Serialize(body, JsonOpts); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await http.PostAsync(url, content, ct); string respText = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { return (false, $"HTTP {(int)response.StatusCode}: {Truncate(respText)}"); } // 飞书返回 code/StatusCode,钉钉/企微返回 errcode,0 均为成功 using var doc = JsonDocument.Parse(respText); var root = doc.RootElement; bool ok = (root.TryGetProperty("errcode", out var ec) && ec.GetInt32() == 0) || (root.TryGetProperty("code", out var c) && c.GetInt32() == 0) || (root.TryGetProperty("StatusCode", out var sc) && sc.GetInt32() == 0); return ok ? (true, null) : (false, Truncate(respText)); } /// /// 读取 appsettings.json(免依赖 Microsoft.Extensions.Configuration,直接 JSON 解析) /// private static void LoadConfig() { try { string path = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); if (!File.Exists(path)) return; using var doc = JsonDocument.Parse(File.ReadAllText(path)); var root = doc.RootElement; if (root.TryGetProperty("ApiBaseUrl", out var u)) _apiBaseUrl = u.GetString() ?? _apiBaseUrl; if (root.TryGetProperty("PollSeconds", out var p)) _pollSeconds = p.GetInt32(); if (root.TryGetProperty("BatchSize", out var b)) _batchSize = b.GetInt32(); } catch (Exception ex) { Log($"读取配置失败,使用默认值: {ex.Message}"); } } private static string Truncate(string s, int max = 200) => string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "..."; private static void Log(string msg) => Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}"); private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; /// /// 主站统一返回结构(仅取所需字段) /// private class ResultDto { public int Code { get; set; } public string? Msg { get; set; } public T? Data { get; set; } } } }