using Microsoft.AspNetCore.WebUtilities; using Microsoft.Graph; using Microsoft.Graph.Models; using Microsoft.Graph.Models.ODataErrors; using Microsoft.Kiota.Abstractions.Authentication; using MimeKit; using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Serialization; using JsonSerializer = System.Text.Json.JsonSerializer; namespace OASystem.API.OAMethodLib.Hotmail { public class HotmailService { private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _config; private readonly SqlSugarClient _sqlSugar; public const string RedisKeyPrefix = "MailAlchemy:Token:"; private readonly ILogger _logger; public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar, ILogger logger) { _httpClientFactory = httpClientFactory; _config = config; _sqlSugar = sqlSugar; _logger = logger; } /// /// 统一获取 Redis Key /// public static string GetRedisKey(string email) => $"{RedisKeyPrefix}{email.Trim().ToLower()}"; /// /// hotmail 信息验证 /// /// /// public (bool, string) ConfigVerify(HotmailConfig? config) { if (config == null) return (true, "当前用户未配置 hotmail 基础信息。"); if (string.IsNullOrEmpty(config.UserName)) return (true, "当前用户未配置 hotmail 基础信息。"); if (string.IsNullOrEmpty(config.ClientId)) return (true, "当前用户未配置 hotmail 租户标识符 (Guid)。"); if (string.IsNullOrEmpty(config.TenantId)) return (true, "当前用户未配置 hotmail 应用程序的客户端标识。"); if (string.IsNullOrEmpty(config.ClientSecret)) return (true, "当前用户未配置 hotmail 应用程序密钥。"); if (string.IsNullOrEmpty(config.RedirectUri)) return (true, "当前用户未配置 hotmail OAuth2 回调重定向地址。"); return (true, ""); } /// /// Microsoft 鉴权预处理 /// public async Task<(int status, string msg)> PrepareAuth(int userId) { // 1. 基础配置校验 (SqlSugar 优化) var userConfig = await GetUserMailConfig(userId); if (userConfig == null || string.IsNullOrWhiteSpace(userConfig.UserName)) return (-1, "账号基础配置缺失"); // 2. 状态检查 (Redis) var redisKey = GetRedisKey(userConfig.UserName); var repo = RedisRepository.RedisFactory.CreateRedisRepository(); var cachedJson = await repo.StringGetAsync(redisKey); if (!string.IsNullOrWhiteSpace(cachedJson)) return (0, "已通过验证,无需重复操作"); // 3. 参数净化与严谨性 var clientId = userConfig.ClientId?.Trim(); var redirectUri = userConfig.RedirectUri?.Trim().Split('\r', '\n')[0]; // 取第一行并修剪 if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri)) return (-1, "ClientId 或 RedirectUri 配置无效"); // 4. 构建长效授权 URL const string authEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; var queryParams = new Dictionary { { "client_id", clientId }, { "response_type", "code" }, { "redirect_uri", redirectUri }, { "response_mode", "query" }, // 核心:必须包含 offline_access 且建议加上 openid { "scope", "openid offline_access Mail.ReadWrite Mail.Send User.Read" }, { "state", userId.ToString() }, // 简单场景使用 userId,安全场景建议使用加密 Hash { "prompt", "consent" } // 关键:确保触发长效令牌授权 }; var authUrl = QueryHelpers.AddQueryString(authEndpoint, queryParams); // 准则 4a: 直接返回结果 return (1, authUrl); } public async Task> GetMergedMessagesAsync(List emails, DateTime cstStart, DateTime cstEnd) { _logger.LogInformation("Microsoft Hotmail -> 获取hotmail邮件信息线程准备"); // 线程安全的合并容器 var allMessages = new ConcurrentBag(); // 转换过滤条件 (建议预先处理) string startFilter = CommonFun.ToGraphUtcString(cstStart); string endFilter = CommonFun.ToGraphUtcString(cstEnd); // 配置并发参数:限制最大并行度,防止被 Graph API 熔断 var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = 5 // 根据服务器性能调整 }; string connectionString = _config.GetConnectionString("OA2023DB"); await Parallel.ForEachAsync(emails, parallelOptions, async (email, ct) => { var config = new ConnectionConfig() { ConfigId = "Parallel_Task_" + email, // 动态 ID,防止干扰 ConnectionString = connectionString, DbType = DbType.SqlServer, IsAutoCloseConnection = true, // 必须:执行完立即释放物理连接 InitKeyType = InitKeyType.Attribute }; // 每一个并发线程都拥有一个完全属于自己的“小炉子”(数据库客户端) using var db = new SqlSugarClient(config); try { _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取token ",email); var client = await GetClientAsync(email, db); _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Graph客户端,刷新token ", email); var response = await client.Me.Messages.GetAsync(q => { q.QueryParameters.Filter = $"receivedDateTime ge {startFilter} and receivedDateTime le {endFilter}"; q.QueryParameters.Select = new[] { "id", "subject", "from", "bodyPreview", "receivedDateTime" }; q.QueryParameters.Orderby = new[] { "receivedDateTime desc" }; q.QueryParameters.Top = 50; // 生产环境建议增加 Top 限制 }, ct); _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Hotmail收件箱资料 ", email); if (response?.Value != null) { var chinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"); foreach (var m in response.Value) { allMessages.Add(new MailDto { MessageId = m.Id, Subject = m.Subject, Content = m.BodyPreview, From = m.From?.EmailAddress?.Address, To = email, ReceivedTime = m.ReceivedDateTime?.DateTime != null ? TimeZoneInfo.ConvertTimeFromUtc(m.ReceivedDateTime.Value.DateTime, chinaTimeZone) : DateTime.MinValue, Source = email // 显式来源 }); } _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Hotmail收件箱资料 成功 ", email); } else { _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Hotmail收件箱资料 暂无 ", email); } } catch (Exception ex) { // 生产环境应接入 ILogger _logger.LogError(ex, "Failed to fetch mail for {Email}", email); } }); // 最终排序并输出 return allMessages.OrderByDescending(m => m.ReceivedTime).ToList(); } /// /// 指定账户发送邮件 /// public async Task SendMailAsync(string fromEmail, MailDto mail) { try { var client = await GetClientAsync(fromEmail, _sqlSugar); // 1. 构建附件列表 var graphAttachments = new List(); if (mail.AttachmentPaths?.Any() == true) { // 获取物理根路径 string baseDir = AppSettingsHelper.Get("InvitationAIAssistBasePath"); foreach (var path in mail.AttachmentPaths) { // 相对路径,需要提取出文件名并拼接物理全路径 string fileName = Path.GetFileName(path); string directoryPath = Path.GetDirectoryName(path); string dirName = Path.GetFileName(directoryPath); string fullPath = Path.Combine(baseDir, dirName, fileName); if (System.IO.File.Exists(fullPath)) { byte[] contentBytes = await System.IO.File.ReadAllBytesAsync(fullPath); graphAttachments.Add(new FileAttachment { Name = fileName, ContentBytes = contentBytes, ContentType = MimeTypes.GetMimeType(fileName) // 需要安装 MimeTypes 库或手动判断 }); } } } var requestBody = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody { Message = new Message { Subject = mail.Subject, Body = new ItemBody { Content = mail.Content, ContentType = BodyType.Html }, ToRecipients = new List { new Recipient { EmailAddress = new EmailAddress { Address = mail.To } } }, Attachments = graphAttachments } }; // 执行发送 await client.Me.SendMail.PostAsync(requestBody); return new MailSendResult { IsSuccess = true, Message = "邮件发送成功!" }; } catch (ODataError odataError) // 捕获 Graph 特有异常 { // 常见的错误:ErrorInvalidUser, ErrorQuotaExceeded, ErrorMessageSubmissionBlocked var code = odataError.Error?.Code ?? "Unknown"; var msg = odataError.Error?.Message ?? "微软 API 调用异常"; return new MailSendResult { IsSuccess = false, ErrorCode = code, Message = $"发送失败: {msg}" }; } catch (Exception ex) { return new MailSendResult { IsSuccess = false, ErrorCode = "InternalError", Message = $"系统内部错误: {ex.Message}" }; } } /// /// 获取邮箱配置信息 - single /// /// public async Task GetUserMailConfig(int userId) { var allConfigs = await GetUserMailConfigListAsync(_sqlSugar); if (allConfigs == null || !allConfigs.Any()) return null; var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId); return userConfig; } /// /// 获取邮箱配置信息 - ALL /// /// public async Task?> GetUserMailConfigListAsync(ISqlSugarClient db) { var remark = await db.Queryable() .Where(x => x.IsDel == 0 && x.Id == 1555 && x.STid == 137) .Select(x => x.Remark) .FirstAsync(); if (string.IsNullOrWhiteSpace(remark)) return null; try { var allConfigs = JsonConvert.DeserializeObject>(remark); return allConfigs; } catch (Exception) { return null; } } /// /// 线程锁 /// private static readonly ConcurrentDictionary _userLocks = new ConcurrentDictionary(); /// /// 获取 Graph 客户端,处理 Token 自动刷新 (线程安全版) /// private async Task GetClientAsync(string email, ISqlSugarClient db) { // 获取或创建针对该 Email 的独立信号量锁 var userLock = _userLocks.GetOrAdd(email, _ => new SemaphoreSlim(1, 1)); await userLock.WaitAsync(); try { var redisKey = GetRedisKey(email); // 建议:每次获取 Repo 实例,避免单例 Repo 内部并发冲突 var repo = RedisRepository.RedisFactory.CreateRedisRepository(); var cachedJson = await repo.StringGetAsync(redisKey); if (string.IsNullOrEmpty(cachedJson)) throw new UnauthorizedAccessException($"Account {email} not initialized in Redis."); var token = System.Text.Json.JsonSerializer.Deserialize(cachedJson!)!; // 令牌过期预校验 (带锁保护,防止并发刷新导致的 Token 失效) if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5)) { // 内部逻辑:调用 Graph 刷新接口 -> 更新 token 对象 -> 写入 Redis token = await RefreshAndSaveTokenAsync(token, db); // 调试建议:记录刷新日志 // _logger.LogInformation("Token refreshed for {Email}", email); } // 3. 构造认证提供者 (Scoped 局部化) // 使用 StaticTokenProvider 封装当前的 AccessToken var tokenProvider = new StaticTokenProvider(token.AccessToken); var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider); // 4. 返回全新的客户端实例,确保 RequestAdapter 隔离 return new GraphServiceClient(authProvider); } catch (Exception ex) { _logger.LogError(ex, "GetClientAsync failed for {Email}", email); throw; } finally { userLock.Release(); // 必须在 finally 中释放锁 } } public async Task RefreshAndSaveTokenAsync(UserToken oldToken, ISqlSugarClient db) { // 1. 实时获取该用户对应的配置信息 // 准则:不再信任全局 _config,而是根据 Email 溯源配置 var allConfigs = await GetUserMailConfigListAsync(db); var currentConfig = allConfigs?.FirstOrDefault(x => x.UserName.Equals(oldToken.Email, StringComparison.OrdinalIgnoreCase)); if (currentConfig == null) throw new Exception($"刷新失败:未能在配置库中找到账号 {oldToken.Email} 的关联 Client 信息。"); // 2. 使用该账号专属的凭据构造请求 var httpClient = _httpClientFactory.CreateClient(); var kvp = new Dictionary { { "client_id", currentConfig.ClientId.Trim() }, { "client_secret", currentConfig.ClientSecret.Trim() }, { "grant_type", "refresh_token" }, { "refresh_token", oldToken.RefreshToken }, { "scope", "openid offline_access Mail.ReadWrite Mail.Send User.Read" } // 保持 Scope 一致性 }; var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", new FormUrlEncodedContent(kvp)); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(); throw new Exception($"微软刷新token接口拒绝请求: {error}"); } using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var root = doc.RootElement; // 3. 构造新令牌 (注意:每次刷新都会返回新的 RefreshToken,必须覆盖旧的) var newToken = new UserToken { Email = oldToken.Email, AccessToken = root.GetProperty("access_token").GetString()!, // 关键:微软可能会滚动更新 RefreshToken,务必取回最新的 RefreshToken = root.TryGetProperty("refresh_token", out var rt) ? rt.GetString()! : oldToken.RefreshToken, ExpiresAt = DateTime.UtcNow.AddSeconds(root.GetProperty("expires_in").GetInt32()), Source = "Microsoft_Graph_Refreshed" }; // 4. 同步更新 Redis (保持 90 天长效) var redisKey = GetRedisKey(oldToken.Email); await RedisRepository.RedisFactory.CreateRedisRepository() .StringSetAsync(redisKey, JsonSerializer.Serialize(newToken), TimeSpan.FromDays(90)); return newToken; } /// /// 强制手动刷新指定邮箱的 Token /// public async Task ForceRefreshTokenAsync(string email) { // 获取用户独占锁,防止手动触发与自动触发冲突 var userLock = _userLocks.GetOrAdd(email, _ => new SemaphoreSlim(1, 1)); await userLock.WaitAsync(); try { var redisKey = GetRedisKey(email); var repo = RedisRepository.RedisFactory.CreateRedisRepository(); var cachedJson = await repo.StringGetAsync(redisKey); if (string.IsNullOrEmpty(cachedJson)) return false; var currentToken = System.Text.Json.JsonSerializer.Deserialize(cachedJson); // 强制进入刷新逻辑 await RefreshAndSaveTokenAsync(currentToken!, _sqlSugar); return true; } finally { userLock.Release(); } } /// /// 静态 Token 提供者辅助类 /// public class StaticTokenProvider : IAccessTokenProvider { private readonly string _token; public StaticTokenProvider(string token) => _token = token; public Task GetAuthorizationTokenAsync(Uri uri, Dictionary? context = null, CancellationToken ct = default) => Task.FromResult(_token); public AllowedHostsValidator AllowedHostsValidator { get; } = new(); } #region 数据模型 public class MailSendResult { public bool IsSuccess { get; set; } public string Message { get; set; } = string.Empty; public string? ErrorCode { get; set; } // Microsoft 提供的错误码 public string Source => "Microsoft_Graph_API"; } /// /// Hotmail 邮件服务 OAuth2 配置信息实体 /// public class HotmailConfig { /// /// 用户唯一标识 /// [JsonPropertyName("userId")] public int UserId { get; set; } /// /// 账号用户名 /// [JsonPropertyName("userName")] public string UserName { get; set; } /// /// Azure AD 租户标识符 (Guid) /// [JsonPropertyName("tenantId")] public string TenantId { get; set; } /// /// 注册应用程序的客户端标识 /// [JsonPropertyName("clientId")] public string ClientId { get; set; } /// /// 客户端密钥(敏感数据建议加密存储) /// [JsonPropertyName("clientSecret")] public string ClientSecret { get; set; } /// /// 租户类型(如 common, organizations 或具体域名) /// [JsonPropertyName("tenant")] public string Tenant { get; set; } = "common"; /// /// OAuth2 回调重定向地址 /// [JsonPropertyName("redirectUri")] public string RedirectUri { get; set; } } public class UserToken { public string Email { get; set; } public string AccessToken { get; set; } public string RefreshToken { get; set; } public DateTime ExpiresAt { get; set; } public string Source { get; set; } } /// /// 邮件请求对象 /// public class MailDto { /// /// 邮件唯一标识符 (UID/Message-ID) /// [JsonPropertyName("messageId")] public string? MessageId { get; set; } /// /// 邮件主题 /// [JsonPropertyName("subject")] public string? Subject { get; set; } /// /// 发件人地址 (e.g. "sender@example.com") /// [JsonPropertyName("from")] public string? From { get; set; } /// /// 收件人地址 /// [JsonPropertyName("to")] public string? To { get; set; } /// /// 邮件正文内容 (HTML 或纯文本) /// [JsonPropertyName("content")] public string? Content { get; set; } /// /// 附件地址 /// [JsonPropertyName("attachments")] public List AttachmentPaths { get; set; } = new List(); /// /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性 /// [JsonPropertyName("receivedTime")] public DateTimeOffset? ReceivedTime { get; set; } /// /// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData") /// [JsonPropertyName("source")] public string? Source { get; set; } = "Hotmail"; } #endregion } }