148 lines
6.7 KiB
C#
148 lines
6.7 KiB
C#
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 结果均做进程级内存缓存,
|
||
/// 避免每条告警都调用飞书 API(token 有效期约 7200s,open_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();
|
||
}
|
||
}
|
||
}
|
||
}
|