| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- using Microsoft.AspNetCore.WebUtilities;
- using Microsoft.Graph;
- using Microsoft.Graph.Models;
- using Microsoft.Graph.Models.ODataErrors;
- using Microsoft.Kiota.Abstractions.Authentication;
- 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:";
- public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar)
- {
- _httpClientFactory = httpClientFactory;
- _config = config;
- _sqlSugar = sqlSugar;
- }
- /// <summary>
- /// 统一获取 Redis Key
- /// </summary>
- public static string GetRedisKey(string email) => $"{RedisKeyPrefix}{email.Trim().ToLower()}";
- /// <summary>
- /// hotmail 信息验证
- /// </summary>
- /// <param name="config"></param>
- /// <returns></returns>
- 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, "");
- }
- /// <summary>
- /// Microsoft 鉴权预处理
- /// </summary>
- 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<string>(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<string, string?>
- {
- { "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<List<MailDto>> GetMergedMessagesAsync(List<string> emails, DateTime cstStart, DateTime cstEnd)
- {
- // 线程安全的合并容器
- var allMessages = new ConcurrentBag<MailDto>();
- // 转换过滤条件 (建议预先处理)
- string startFilter = CommonFun.ToGraphUtcString(cstStart);
- string endFilter = CommonFun.ToGraphUtcString(cstEnd);
- // 配置并发参数:限制最大并行度,防止被 Graph API 熔断
- var parallelOptions = new ParallelOptions
- {
- MaxDegreeOfParallelism = 5 // 根据服务器性能调整
- };
- await Parallel.ForEachAsync(emails, parallelOptions, async (email, ct) =>
- {
- try
- {
- var client = await GetClientAsync(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);
- 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 // 显式来源
- });
- }
- }
- }
- catch (Exception ex)
- {
- // 生产环境应接入 ILogger
- //_logger.LogError(ex, "Failed to fetch mail for {Email}", email);
- }
- });
- // 最终排序并输出
- return allMessages.OrderByDescending(m => m.ReceivedTime).ToList();
- }
- /// <summary>
- /// 指定账户发送邮件
- /// </summary>
- public async Task<MailSendResult> SendMailAsync(string fromEmail, MailDto mail)
- {
- try
- {
- var client = await GetClientAsync(fromEmail);
- 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<Recipient>
- {
- new Recipient { EmailAddress = new EmailAddress { Address = mail.To } }
- }
- }
- };
- // 执行发送
- 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}"
- };
- }
- }
- /// <summary>
- /// 获取邮箱配置信息 - single
- /// </summary>
- /// <returns></returns>
- public async Task<HotmailConfig?> GetUserMailConfig(int userId)
- {
- var allConfigs = await GetUserMailConfigListAsync();
- if (allConfigs == null || !allConfigs.Any()) return null;
- var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId);
- return userConfig;
- }
- /// <summary>
- /// 获取邮箱配置信息 - ALL
- /// </summary>
- /// <returns></returns>
- public async Task<List<HotmailConfig>?> GetUserMailConfigListAsync()
- {
- var remark = await _sqlSugar.Queryable<Sys_SetData>()
- .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<List<HotmailConfig>>(remark);
- return allConfigs;
- }
- catch (Exception)
- {
- return null;
- }
- }
- /// <summary>
- /// 线程锁
- /// </summary>
- private static readonly ConcurrentDictionary<string, SemaphoreSlim> _userLocks = new ConcurrentDictionary<string, SemaphoreSlim>();
- /// <summary>
- /// 获取 Graph 客户端,处理 Token 自动刷新 (线程安全版)
- /// </summary>
- private async Task<GraphServiceClient> GetClientAsync(string email)
- {
- // 获取或创建针对该 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<string>(redisKey);
- if (string.IsNullOrEmpty(cachedJson))
- throw new UnauthorizedAccessException($"Account {email} not initialized in Redis.");
- var token = System.Text.Json.JsonSerializer.Deserialize<UserToken>(cachedJson!)!;
- // 令牌过期预校验 (带锁保护,防止并发刷新导致的 Token 失效)
- if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5))
- {
- // 内部逻辑:调用 Graph 刷新接口 -> 更新 token 对象 -> 写入 Redis
- token = await RefreshAndSaveTokenAsync(token);
- // 调试建议:记录刷新日志
- // _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<UserToken> RefreshAndSaveTokenAsync(UserToken oldToken)
- {
- // 1. 实时获取该用户对应的配置信息
- // 准则:不再信任全局 _config,而是根据 Email 溯源配置
- var allConfigs = await GetUserMailConfigListAsync();
- 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<string, string>
- {
- { "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($"微软刷新接口拒绝请求: {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;
- }
- /// <summary>
- /// 静态 Token 提供者辅助类
- /// </summary>
- public class StaticTokenProvider : IAccessTokenProvider
- {
- private readonly string _token;
- public StaticTokenProvider(string token) => _token = token;
- public Task<string> GetAuthorizationTokenAsync(Uri uri, Dictionary<string, object>? 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";
- }
- /// <summary>
- /// Hotmail 邮件服务 OAuth2 配置信息实体
- /// </summary>
- public class HotmailConfig
- {
- /// <summary>
- /// 用户唯一标识
- /// </summary>
- [JsonPropertyName("userId")]
- public int UserId { get; set; }
- /// <summary>
- /// 账号用户名
- /// </summary>
- [JsonPropertyName("userName")]
- public string UserName { get; set; }
- /// <summary>
- /// Azure AD 租户标识符 (Guid)
- /// </summary>
- [JsonPropertyName("tenantId")]
- public string TenantId { get; set; }
- /// <summary>
- /// 注册应用程序的客户端标识
- /// </summary>
- [JsonPropertyName("clientId")]
- public string ClientId { get; set; }
- /// <summary>
- /// 客户端密钥(敏感数据建议加密存储)
- /// </summary>
- [JsonPropertyName("clientSecret")]
- public string ClientSecret { get; set; }
- /// <summary>
- /// 租户类型(如 common, organizations 或具体域名)
- /// </summary>
- [JsonPropertyName("tenant")]
- public string Tenant { get; set; } = "common";
- /// <summary>
- /// OAuth2 回调重定向地址
- /// </summary>
- [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; }
- }
- /// <summary>
- /// 邮件请求对象
- /// </summary>
- public class MailDto
- {
- /// <summary>
- /// 邮件唯一标识符 (UID/Message-ID)
- /// </summary>
- [JsonPropertyName("messageId")]
- public string? MessageId { get; set; }
- /// <summary>
- /// 邮件主题
- /// </summary>
- [JsonPropertyName("subject")]
- public string? Subject { get; set; }
- /// <summary>
- /// 发件人地址 (e.g. "sender@example.com")
- /// </summary>
- [JsonPropertyName("from")]
- public string? From { get; set; }
- /// <summary>
- /// 收件人地址
- /// </summary>
- [JsonPropertyName("to")]
- public string? To { get; set; }
- /// <summary>
- /// 邮件正文内容 (HTML 或纯文本)
- /// </summary>
- [JsonPropertyName("content")]
- public string? Content { get; set; }
- /// <summary>
- /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性
- /// </summary>
- [JsonPropertyName("receivedTime")]
- public DateTimeOffset? ReceivedTime { get; set; }
- /// <summary>
- /// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData")
- /// </summary>
- [JsonPropertyName("source")]
- public string? Source { get; set; } = "Hotmail";
- }
- #endregion
- }
- }
|