搭建消息告警模块以及跨网推送服务,支持企微,飞书,钉钉通知

This commit is contained in:
2026-09-03 14:02:35 +08:00
parent cfa8e78d33
commit 3927b0c201
40 changed files with 4360 additions and 7 deletions
@@ -0,0 +1,95 @@
using Common.Notify;
using Model.Entity.Inspection;
using Service.Interface;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 钉钉群机器人通知发送器(自定义机器人 Webhook,支持加签校验)
/// 报文:msgtype=markdownat.atMobiles 支持 @手机号;
/// 加签:URL 追加 timestamp(毫秒)+sign(算法见 ImWebhookSigner.DingTalkSign
/// </summary>
public class DingTalkNotifier : IAlertNotifier
{
private readonly HttpClient _httpClient;
private readonly ILogger<DingTalkNotifier> _logger;
public DingTalkNotifier(HttpClient httpClient, ILogger<DingTalkNotifier> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.DingTalk;
public async Task<NotifyResult> SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default)
{
try
{
string url = webhookUrl;
// 加签:URL 追加 timestamp 与 sign 参数
if (!string.IsNullOrWhiteSpace(secret))
{
long timestampMs = DateTimeOffset.Now.ToUnixTimeMilliseconds();
string sign = ImWebhookSigner.DingTalkSign(timestampMs, secret);
url += $"{(url.Contains('?') ? "&" : "?")}timestamp={timestampMs}&sign={sign}";
}
// markdown 正文中 @ 手机号需同时出现在 atMobiles 与正文里才会真正提醒
var text = new StringBuilder();
text.AppendLine($"### {message.Title}");
text.AppendLine(message.Content);
foreach (var mobile in message.AtMobiles)
{
text.Append($"@{mobile} ");
}
var body = new Dictionary<string, object>
{
["msgtype"] = "markdown",
["markdown"] = new Dictionary<string, object>
{
["title"] = message.Title,
["text"] = text.ToString()
},
["at"] = new Dictionary<string, object>
{
["atMobiles"] = message.AtMobiles,
["isAtAll"] = false
}
};
string json = JsonSerializer.Serialize(body);
_logger.LogInformation("钉钉推送 atMobiles=[{At}]", string.Join(",", message.AtMobiles));
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _httpClient.PostAsync(url, content, ct);
string respText = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}");
}
// 钉钉返回 {"errcode":0,"errmsg":"ok"}
using var doc = JsonDocument.Parse(respText);
bool ok = doc.RootElement.TryGetProperty("errcode", out var errcode) && errcode.GetInt32() == 0;
return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText));
}
catch (Exception ex)
{
return NotifyResult.Fail($"钉钉推送异常: {NotifyError.Describe(ex)}");
}
}
private static string Truncate(string s, int max = 200) =>
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
}
}
@@ -0,0 +1,147 @@
using Common.Notify;
using Microsoft.Extensions.Logging;
using Service.Interface;
using System;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 飞书通讯录服务实现:手机号 → open_id
/// 依赖飞书自建应用凭证(FeishuConfig)tenant_access_token 与 open_id 结果均做进程级内存缓存,
/// 避免每条告警都调用飞书 APItoken 有效期约 7200sopen_id 基本不变)。
/// </summary>
public class FeishuContactService : IFeishuContactService
{
private const string TokenUrl = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
private const string BatchGetIdUrl = "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<FeishuContactService> _logger;
// tenant_access_token 进程级缓存(提前 5 分钟刷新,避免边界失效)
private static string? _token;
private static DateTime _tokenExpireAt = DateTime.MinValue;
private static readonly SemaphoreSlim _tokenLock = new(1, 1);
// 手机号 → open_id 结果缓存(换不到的也缓存 null 避免反复调用;重启进程可重置)
private static readonly ConcurrentDictionary<string, string?> _openIdCache = new();
public FeishuContactService(IHttpClientFactory httpClientFactory, ILogger<FeishuContactService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <summary>
/// 用手机号换取飞书 open_id;未配置凭证、查无此人或无权限时返回 null
/// </summary>
public async Task<string?> GetOpenIdByMobileAsync(string mobile)
{
if (string.IsNullOrWhiteSpace(mobile)) return null;
mobile = mobile.Trim();
if (_openIdCache.TryGetValue(mobile, out var cached)) return cached;
if (!FeishuConfig.IsConfigured)
{
_logger.LogWarning("飞书应用凭证未配置(AppId/AppSecret),无法把手机号 {Mobile} 转 open_id;请在 appsettings.json 的 Feishu 节配置", mobile);
return null;
}
try
{
var token = await GetTenantAccessTokenAsync();
if (string.IsNullOrEmpty(token)) return null;
var http = _httpClientFactory.CreateClient();
string body = JsonSerializer.Serialize(new { mobiles = new[] { mobile } });
using var req = new HttpRequestMessage(HttpMethod.Post, BatchGetIdUrl);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var resp = await http.SendAsync(req);
string respText = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(respText);
var root = doc.RootElement;
int code = root.TryGetProperty("code", out var c) ? c.GetInt32() : -1;
if (code != 0)
{
string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : "";
_logger.LogWarning("飞书手机号换 open_id 失败 mobile={Mobile} code={Code} msg={Msg}(多为权限未开通或通讯录范围不含该成员)", mobile, code, msg);
_openIdCache[mobile] = null;
return null;
}
string? openId = null;
if (root.TryGetProperty("data", out var data) &&
data.TryGetProperty("user_list", out var list) &&
list.ValueKind == JsonValueKind.Array && list.GetArrayLength() > 0 &&
list[0].TryGetProperty("user_id", out var uid))
{
openId = uid.GetString();
}
if (string.IsNullOrEmpty(openId))
_logger.LogWarning("飞书手机号 {Mobile} 未查到 open_id(该手机号可能不在应用通讯录范围内)", mobile);
_openIdCache[mobile] = openId;
return openId;
}
catch (Exception ex)
{
_logger.LogError("飞书手机号换 open_id 异常 mobile={Mobile}: {Err}", mobile, NotifyError.Describe(ex));
return null;
}
}
/// <summary>
/// 获取 tenant_access_token(进程级缓存,提前 5 分钟刷新)
/// </summary>
private async Task<string?> GetTenantAccessTokenAsync()
{
if (!string.IsNullOrEmpty(_token) && DateTime.Now < _tokenExpireAt) return _token;
await _tokenLock.WaitAsync();
try
{
if (!string.IsNullOrEmpty(_token) && DateTime.Now < _tokenExpireAt) return _token;
var http = _httpClientFactory.CreateClient();
string body = JsonSerializer.Serialize(new { app_id = FeishuConfig.AppId, app_secret = FeishuConfig.AppSecret });
using var content = new StringContent(body, Encoding.UTF8, "application/json");
using var resp = await http.PostAsync(TokenUrl, content);
string respText = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(respText);
var root = doc.RootElement;
int code = root.TryGetProperty("code", out var c) ? c.GetInt32() : -1;
if (code != 0)
{
string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : "";
_logger.LogError("飞书获取 tenant_access_token 失败 code={Code} msg={Msg}(检查 AppId/AppSecret", code, msg);
return null;
}
_token = root.TryGetProperty("tenant_access_token", out var t) ? t.GetString() : null;
int expire = root.TryGetProperty("expire", out var e) ? e.GetInt32() : 7200;
_tokenExpireAt = DateTime.Now.AddSeconds(Math.Max(60, expire - 300));
return _token;
}
catch (Exception ex)
{
_logger.LogError("飞书获取 tenant_access_token 异常: {Err}", NotifyError.Describe(ex));
return null;
}
finally
{
_tokenLock.Release();
}
}
}
}
@@ -0,0 +1,113 @@
using Common.Notify;
using Model.Entity.Inspection;
using Service.Interface;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 飞书群机器人通知发送器(自定义机器人 Webhook,支持加签校验)
/// 报文:msg_type=post 富文本,用 at 标签(user_id=open_id)真正 @ 人;加签时附 timestamp(秒)+sign(算法见 ImWebhookSigner.FeishuSign
/// </summary>
public class FeishuNotifier : IAlertNotifier
{
private readonly HttpClient _httpClient;
private readonly ILogger<FeishuNotifier> _logger;
public FeishuNotifier(HttpClient httpClient, ILogger<FeishuNotifier> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.Feishu;
public async Task<NotifyResult> SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default)
{
try
{
// 飞书 text 消息不支持结构化 @,必须用 post 富文本 + at 标签才能真正 @ 到人。
// at 标签的 user_id 为飞书 open_id(ou_ 开头);如需 @ 所有人,接收人填 "all"。
var contentLines = new List<List<Dictionary<string, object>>>();
foreach (var raw in (message.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 (message.AtMobiles.Count > 0)
{
var atLine = new List<Dictionary<string, object>>
{
new Dictionary<string, object> { ["tag"] = "text", ["text"] = "关注人: " }
};
foreach (var openId in message.AtMobiles)
{
if (string.IsNullOrWhiteSpace(openId)) continue;
atLine.Add(new Dictionary<string, object> { ["tag"] = "at", ["user_id"] = openId.Trim() });
}
contentLines.Add(atLine);
}
var body = new Dictionary<string, object>
{
["msg_type"] = "post",
["content"] = new Dictionary<string, object>
{
["post"] = new Dictionary<string, object>
{
["zh_cn"] = new Dictionary<string, object>
{
["title"] = message.Title,
["content"] = contentLines
}
}
}
};
// 加签:timestamp(秒) + sign
if (!string.IsNullOrWhiteSpace(secret))
{
long timestampSec = DateTimeOffset.Now.ToUnixTimeSeconds();
body["timestamp"] = timestampSec.ToString();
body["sign"] = ImWebhookSigner.FeishuSign(timestampSec, secret);
}
string json = JsonSerializer.Serialize(body);
_logger.LogInformation("飞书推送(post富文本) @open_id=[{At}]", string.Join(",", message.AtMobiles));
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _httpClient.PostAsync(webhookUrl, content, ct);
string respText = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}");
}
// 飞书返回 {"code":0,...}(旧版为 {"StatusCode":0,...}
using var doc = JsonDocument.Parse(respText);
var root = doc.RootElement;
bool ok = (root.TryGetProperty("code", out var code) && code.GetInt32() == 0)
|| (root.TryGetProperty("StatusCode", out var sc) && sc.GetInt32() == 0);
return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText));
}
catch (Exception ex)
{
return NotifyResult.Fail($"飞书推送异常: {NotifyError.Describe(ex)}");
}
}
private static string Truncate(string s, int max = 200) =>
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
}
}
@@ -0,0 +1,75 @@
using Model.Entity.Inspection;
using Service.Interface;
using Common.Notify;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Service.Implement
{
/// <summary>
/// 企业微信群机器人通知发送器(群机器人 Webhook 无加签机制)
/// 报文:msgtype=texttext 类型才支持 mentioned_mobile_list @手机号)
/// </summary>
public class WeComNotifier : IAlertNotifier
{
private readonly HttpClient _httpClient;
private readonly ILogger<WeComNotifier> _logger;
public WeComNotifier(HttpClient httpClient, ILogger<WeComNotifier> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public NotifyChannelTypeEnum ChannelType => NotifyChannelTypeEnum.WeCom;
public async Task<NotifyResult> SendAsync(AlertNotifyMessage message, string webhookUrl, string? secret, CancellationToken ct = default)
{
try
{
var text = new StringBuilder();
text.AppendLine(message.Title);
text.AppendLine(message.Content);
var body = new Dictionary<string, object>
{
["msgtype"] = "text",
["text"] = new Dictionary<string, object>
{
["content"] = text.ToString(),
["mentioned_mobile_list"] = message.AtMobiles
}
};
string json = JsonSerializer.Serialize(body);
_logger.LogInformation("企微推送 mentioned_mobile_list=[{At}]", string.Join(",", message.AtMobiles));
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _httpClient.PostAsync(webhookUrl, content, ct);
string respText = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
return NotifyResult.Fail($"HTTP {(int)response.StatusCode}: {Truncate(respText)}");
}
// 企微返回 {"errcode":0,"errmsg":"ok"}
using var doc = JsonDocument.Parse(respText);
bool ok = doc.RootElement.TryGetProperty("errcode", out var errcode) && errcode.GetInt32() == 0;
return ok ? NotifyResult.Ok() : NotifyResult.Fail(Truncate(respText));
}
catch (Exception ex)
{
return NotifyResult.Fail($"企微推送异常: {NotifyError.Describe(ex)}");
}
}
private static string Truncate(string s, int max = 200) =>
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
}
}