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; }
}
}
}