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

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,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();
}
}
}
}