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 { /// /// 飞书通讯录服务实现:手机号 → open_id /// 依赖飞书自建应用凭证(FeishuConfig);tenant_access_token 与 open_id 结果均做进程级内存缓存, /// 避免每条告警都调用飞书 API(token 有效期约 7200s,open_id 基本不变)。 /// 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 _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 _openIdCache = new(); public FeishuContactService(IHttpClientFactory httpClientFactory, ILogger logger) { _httpClientFactory = httpClientFactory; _logger = logger; } /// /// 用手机号换取飞书 open_id;未配置凭证、查无此人或无权限时返回 null /// public async Task 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; } } /// /// 获取 tenant_access_token(进程级缓存,提前 5 分钟刷新) /// private async Task 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(); } } } }