HotmailService.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. using Microsoft.AspNetCore.WebUtilities;
  2. using Microsoft.Graph;
  3. using Microsoft.Graph.Models;
  4. using Microsoft.Graph.Models.ODataErrors;
  5. using Microsoft.Kiota.Abstractions.Authentication;
  6. using MimeKit;
  7. using System.Collections.Concurrent;
  8. using System.Text.Json;
  9. using System.Text.Json.Serialization;
  10. using JsonSerializer = System.Text.Json.JsonSerializer;
  11. namespace OASystem.API.OAMethodLib.Hotmail
  12. {
  13. public class HotmailService
  14. {
  15. private readonly IHttpClientFactory _httpClientFactory;
  16. private readonly IConfiguration _config;
  17. private readonly SqlSugarClient _sqlSugar;
  18. public const string RedisKeyPrefix = "MailAlchemy:Token:";
  19. private readonly ILogger<HotmailService> _logger;
  20. public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar, ILogger<HotmailService> logger)
  21. {
  22. _httpClientFactory = httpClientFactory;
  23. _config = config;
  24. _sqlSugar = sqlSugar;
  25. _logger = logger;
  26. }
  27. /// <summary>
  28. /// 统一获取 Redis Key
  29. /// </summary>
  30. public static string GetRedisKey(string email) => $"{RedisKeyPrefix}{email.Trim().ToLower()}";
  31. /// <summary>
  32. /// hotmail 信息验证
  33. /// </summary>
  34. /// <param name="config"></param>
  35. /// <returns></returns>
  36. public (bool, string) ConfigVerify(HotmailConfig? config)
  37. {
  38. if (config == null) return (true, "当前用户未配置 hotmail 基础信息。");
  39. if (string.IsNullOrEmpty(config.UserName)) return (true, "当前用户未配置 hotmail 基础信息。");
  40. if (string.IsNullOrEmpty(config.ClientId)) return (true, "当前用户未配置 hotmail 租户标识符 (Guid)。");
  41. if (string.IsNullOrEmpty(config.TenantId)) return (true, "当前用户未配置 hotmail 应用程序的客户端标识。");
  42. if (string.IsNullOrEmpty(config.ClientSecret)) return (true, "当前用户未配置 hotmail 应用程序密钥。");
  43. if (string.IsNullOrEmpty(config.RedirectUri)) return (true, "当前用户未配置 hotmail OAuth2 回调重定向地址。");
  44. return (true, "");
  45. }
  46. /// <summary>
  47. /// Microsoft 鉴权预处理
  48. /// </summary>
  49. public async Task<(int status, string msg)> PrepareAuth(int userId)
  50. {
  51. // 1. 基础配置校验 (SqlSugar 优化)
  52. var userConfig = await GetUserMailConfig(userId);
  53. if (userConfig == null || string.IsNullOrWhiteSpace(userConfig.UserName))
  54. return (-1, "账号基础配置缺失");
  55. // 2. 状态检查 (Redis)
  56. var redisKey = GetRedisKey(userConfig.UserName);
  57. var repo = RedisRepository.RedisFactory.CreateRedisRepository();
  58. var cachedJson = await repo.StringGetAsync<string>(redisKey);
  59. if (!string.IsNullOrWhiteSpace(cachedJson))
  60. return (0, "已通过验证,无需重复操作");
  61. // 3. 参数净化与严谨性
  62. var clientId = userConfig.ClientId?.Trim();
  63. var redirectUri = userConfig.RedirectUri?.Trim().Split('\r', '\n')[0]; // 取第一行并修剪
  64. if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri))
  65. return (-1, "ClientId 或 RedirectUri 配置无效");
  66. // 4. 构建长效授权 URL
  67. const string authEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
  68. var queryParams = new Dictionary<string, string?>
  69. {
  70. { "client_id", clientId },
  71. { "response_type", "code" },
  72. { "redirect_uri", redirectUri },
  73. { "response_mode", "query" },
  74. // 核心:必须包含 offline_access 且建议加上 openid
  75. { "scope", "openid offline_access Mail.ReadWrite Mail.Send User.Read" },
  76. { "state", userId.ToString() }, // 简单场景使用 userId,安全场景建议使用加密 Hash
  77. { "prompt", "consent" } // 关键:确保触发长效令牌授权
  78. };
  79. var authUrl = QueryHelpers.AddQueryString(authEndpoint, queryParams);
  80. // 准则 4a: 直接返回结果
  81. return (1, authUrl);
  82. }
  83. public async Task<List<MailDto>> GetMergedMessagesAsync(List<string> emails, DateTime cstStart, DateTime cstEnd)
  84. {
  85. _logger.LogInformation("Microsoft Hotmail -> 获取hotmail邮件信息线程准备");
  86. // 线程安全的合并容器
  87. var allMessages = new ConcurrentBag<MailDto>();
  88. // 转换过滤条件 (建议预先处理)
  89. string startFilter = CommonFun.ToGraphUtcString(cstStart);
  90. string endFilter = CommonFun.ToGraphUtcString(cstEnd);
  91. // 配置并发参数:限制最大并行度,防止被 Graph API 熔断
  92. var parallelOptions = new ParallelOptions
  93. {
  94. MaxDegreeOfParallelism = 5 // 根据服务器性能调整
  95. };
  96. string connectionString = _config.GetConnectionString("OA2023DB");
  97. await Parallel.ForEachAsync(emails, parallelOptions, async (email, ct) =>
  98. {
  99. var config = new ConnectionConfig()
  100. {
  101. ConfigId = "Parallel_Task_" + email, // 动态 ID,防止干扰
  102. ConnectionString = connectionString,
  103. DbType = DbType.SqlServer,
  104. IsAutoCloseConnection = true, // 必须:执行完立即释放物理连接
  105. InitKeyType = InitKeyType.Attribute
  106. };
  107. // 每一个并发线程都拥有一个完全属于自己的“小炉子”(数据库客户端)
  108. using var db = new SqlSugarClient(config);
  109. try
  110. {
  111. _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取token ",email);
  112. var client = await GetClientAsync(email, db);
  113. _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Graph客户端,刷新token ", email);
  114. var response = await client.Me.Messages.GetAsync(q =>
  115. {
  116. q.QueryParameters.Filter = $"receivedDateTime ge {startFilter} and receivedDateTime le {endFilter}";
  117. q.QueryParameters.Select = new[] { "id", "subject", "from", "bodyPreview", "receivedDateTime" };
  118. q.QueryParameters.Orderby = new[] { "receivedDateTime desc" };
  119. q.QueryParameters.Top = 50; // 生产环境建议增加 Top 限制
  120. }, ct);
  121. _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Hotmail收件箱资料 ", email);
  122. if (response?.Value != null)
  123. {
  124. var chinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
  125. foreach (var m in response.Value)
  126. {
  127. allMessages.Add(new MailDto
  128. {
  129. MessageId = m.Id,
  130. Subject = m.Subject,
  131. Content = m.BodyPreview,
  132. From = m.From?.EmailAddress?.Address,
  133. To = email,
  134. ReceivedTime = m.ReceivedDateTime?.DateTime != null
  135. ? TimeZoneInfo.ConvertTimeFromUtc(m.ReceivedDateTime.Value.DateTime, chinaTimeZone)
  136. : DateTime.MinValue,
  137. Source = email // 显式来源
  138. });
  139. }
  140. _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Hotmail收件箱资料 成功 ", email);
  141. }
  142. else
  143. {
  144. _logger.LogInformation("Microsoft Hotmail -> [{Hotmail}] 获取Hotmail收件箱资料 暂无 ", email);
  145. }
  146. }
  147. catch (Exception ex)
  148. {
  149. // 生产环境应接入 ILogger
  150. _logger.LogError(ex, "Failed to fetch mail for {Email}", email);
  151. }
  152. });
  153. // 最终排序并输出
  154. return allMessages.OrderByDescending(m => m.ReceivedTime).ToList();
  155. }
  156. /// <summary>
  157. /// 指定账户发送邮件
  158. /// </summary>
  159. public async Task<MailSendResult> SendMailAsync(string fromEmail, MailDto mail)
  160. {
  161. try
  162. {
  163. var client = await GetClientAsync(fromEmail, _sqlSugar);
  164. // 1. 构建附件列表
  165. var graphAttachments = new List<Attachment>();
  166. if (mail.AttachmentPaths?.Any() == true)
  167. {
  168. // 获取物理根路径
  169. string baseDir = AppSettingsHelper.Get("InvitationAIAssistBasePath");
  170. foreach (var path in mail.AttachmentPaths)
  171. {
  172. // 相对路径,需要提取出文件名并拼接物理全路径
  173. string fileName = Path.GetFileName(path);
  174. string directoryPath = Path.GetDirectoryName(path);
  175. string dirName = Path.GetFileName(directoryPath);
  176. string fullPath = Path.Combine(baseDir, dirName, fileName);
  177. if (System.IO.File.Exists(fullPath))
  178. {
  179. byte[] contentBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
  180. graphAttachments.Add(new FileAttachment
  181. {
  182. Name = fileName,
  183. ContentBytes = contentBytes,
  184. ContentType = MimeTypes.GetMimeType(fileName) // 需要安装 MimeTypes 库或手动判断
  185. });
  186. }
  187. }
  188. }
  189. var requestBody = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody
  190. {
  191. Message = new Message
  192. {
  193. Subject = mail.Subject,
  194. Body = new ItemBody
  195. {
  196. Content = mail.Content,
  197. ContentType = BodyType.Html
  198. },
  199. ToRecipients = new List<Recipient>
  200. {
  201. new Recipient { EmailAddress = new EmailAddress { Address = mail.To } }
  202. },
  203. Attachments = graphAttachments
  204. }
  205. };
  206. // 执行发送
  207. await client.Me.SendMail.PostAsync(requestBody);
  208. return new MailSendResult { IsSuccess = true, Message = "邮件发送成功!" };
  209. }
  210. catch (ODataError odataError) // 捕获 Graph 特有异常
  211. {
  212. // 常见的错误:ErrorInvalidUser, ErrorQuotaExceeded, ErrorMessageSubmissionBlocked
  213. var code = odataError.Error?.Code ?? "Unknown";
  214. var msg = odataError.Error?.Message ?? "微软 API 调用异常";
  215. return new MailSendResult
  216. {
  217. IsSuccess = false,
  218. ErrorCode = code,
  219. Message = $"发送失败: {msg}"
  220. };
  221. }
  222. catch (Exception ex)
  223. {
  224. return new MailSendResult
  225. {
  226. IsSuccess = false,
  227. ErrorCode = "InternalError",
  228. Message = $"系统内部错误: {ex.Message}"
  229. };
  230. }
  231. }
  232. /// <summary>
  233. /// 获取邮箱配置信息 - single
  234. /// </summary>
  235. /// <returns></returns>
  236. public async Task<HotmailConfig?> GetUserMailConfig(int userId)
  237. {
  238. var allConfigs = await GetUserMailConfigListAsync(_sqlSugar);
  239. if (allConfigs == null || !allConfigs.Any()) return null;
  240. var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId);
  241. return userConfig;
  242. }
  243. /// <summary>
  244. /// 获取邮箱配置信息 - ALL
  245. /// </summary>
  246. /// <returns></returns>
  247. public async Task<List<HotmailConfig>?> GetUserMailConfigListAsync(ISqlSugarClient db)
  248. {
  249. var remark = await db.Queryable<Sys_SetData>()
  250. .Where(x => x.IsDel == 0 && x.Id == 1555 && x.STid == 137)
  251. .Select(x => x.Remark)
  252. .FirstAsync();
  253. if (string.IsNullOrWhiteSpace(remark)) return null;
  254. try
  255. {
  256. var allConfigs = JsonConvert.DeserializeObject<List<HotmailConfig>>(remark);
  257. return allConfigs;
  258. }
  259. catch (Exception)
  260. {
  261. return null;
  262. }
  263. }
  264. /// <summary>
  265. /// 线程锁
  266. /// </summary>
  267. private static readonly ConcurrentDictionary<string, SemaphoreSlim> _userLocks = new ConcurrentDictionary<string, SemaphoreSlim>();
  268. /// <summary>
  269. /// 获取 Graph 客户端,处理 Token 自动刷新 (线程安全版)
  270. /// </summary>
  271. private async Task<GraphServiceClient> GetClientAsync(string email, ISqlSugarClient db)
  272. {
  273. // 获取或创建针对该 Email 的独立信号量锁
  274. var userLock = _userLocks.GetOrAdd(email, _ => new SemaphoreSlim(1, 1));
  275. await userLock.WaitAsync();
  276. try
  277. {
  278. var redisKey = GetRedisKey(email);
  279. // 建议:每次获取 Repo 实例,避免单例 Repo 内部并发冲突
  280. var repo = RedisRepository.RedisFactory.CreateRedisRepository();
  281. var cachedJson = await repo.StringGetAsync<string>(redisKey);
  282. if (string.IsNullOrEmpty(cachedJson))
  283. throw new UnauthorizedAccessException($"Account {email} not initialized in Redis.");
  284. var token = System.Text.Json.JsonSerializer.Deserialize<UserToken>(cachedJson!)!;
  285. // 令牌过期预校验 (带锁保护,防止并发刷新导致的 Token 失效)
  286. if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5))
  287. {
  288. // 内部逻辑:调用 Graph 刷新接口 -> 更新 token 对象 -> 写入 Redis
  289. token = await RefreshAndSaveTokenAsync(token, db);
  290. // 调试建议:记录刷新日志
  291. // _logger.LogInformation("Token refreshed for {Email}", email);
  292. }
  293. // 3. 构造认证提供者 (Scoped 局部化)
  294. // 使用 StaticTokenProvider 封装当前的 AccessToken
  295. var tokenProvider = new StaticTokenProvider(token.AccessToken);
  296. var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
  297. // 4. 返回全新的客户端实例,确保 RequestAdapter 隔离
  298. return new GraphServiceClient(authProvider);
  299. }
  300. catch (Exception ex)
  301. {
  302. _logger.LogError(ex, "GetClientAsync failed for {Email}", email);
  303. throw;
  304. }
  305. finally
  306. {
  307. userLock.Release(); // 必须在 finally 中释放锁
  308. }
  309. }
  310. public async Task<UserToken> RefreshAndSaveTokenAsync(UserToken oldToken, ISqlSugarClient db)
  311. {
  312. // 1. 实时获取该用户对应的配置信息
  313. // 准则:不再信任全局 _config,而是根据 Email 溯源配置
  314. var allConfigs = await GetUserMailConfigListAsync(db);
  315. var currentConfig = allConfigs?.FirstOrDefault(x =>
  316. x.UserName.Equals(oldToken.Email, StringComparison.OrdinalIgnoreCase));
  317. if (currentConfig == null)
  318. throw new Exception($"刷新失败:未能在配置库中找到账号 {oldToken.Email} 的关联 Client 信息。");
  319. // 2. 使用该账号专属的凭据构造请求
  320. var httpClient = _httpClientFactory.CreateClient();
  321. var kvp = new Dictionary<string, string>
  322. {
  323. { "client_id", currentConfig.ClientId.Trim() },
  324. { "client_secret", currentConfig.ClientSecret.Trim() },
  325. { "grant_type", "refresh_token" },
  326. { "refresh_token", oldToken.RefreshToken },
  327. { "scope", "openid offline_access Mail.ReadWrite Mail.Send User.Read" } // 保持 Scope 一致性
  328. };
  329. var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", new FormUrlEncodedContent(kvp));
  330. if (!response.IsSuccessStatusCode)
  331. {
  332. var error = await response.Content.ReadAsStringAsync();
  333. throw new Exception($"微软刷新token接口拒绝请求: {error}");
  334. }
  335. using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
  336. var root = doc.RootElement;
  337. // 3. 构造新令牌 (注意:每次刷新都会返回新的 RefreshToken,必须覆盖旧的)
  338. var newToken = new UserToken
  339. {
  340. Email = oldToken.Email,
  341. AccessToken = root.GetProperty("access_token").GetString()!,
  342. // 关键:微软可能会滚动更新 RefreshToken,务必取回最新的
  343. RefreshToken = root.TryGetProperty("refresh_token", out var rt) ? rt.GetString()! : oldToken.RefreshToken,
  344. ExpiresAt = DateTime.UtcNow.AddSeconds(root.GetProperty("expires_in").GetInt32()),
  345. Source = "Microsoft_Graph_Refreshed"
  346. };
  347. // 4. 同步更新 Redis (保持 90 天长效)
  348. var redisKey = GetRedisKey(oldToken.Email);
  349. await RedisRepository.RedisFactory.CreateRedisRepository()
  350. .StringSetAsync(redisKey, JsonSerializer.Serialize(newToken), TimeSpan.FromDays(90));
  351. return newToken;
  352. }
  353. /// <summary>
  354. /// 强制手动刷新指定邮箱的 Token
  355. /// </summary>
  356. public async Task<bool> ForceRefreshTokenAsync(string email)
  357. {
  358. // 获取用户独占锁,防止手动触发与自动触发冲突
  359. var userLock = _userLocks.GetOrAdd(email, _ => new SemaphoreSlim(1, 1));
  360. await userLock.WaitAsync();
  361. try
  362. {
  363. var redisKey = GetRedisKey(email);
  364. var repo = RedisRepository.RedisFactory.CreateRedisRepository();
  365. var cachedJson = await repo.StringGetAsync<string>(redisKey);
  366. if (string.IsNullOrEmpty(cachedJson)) return false;
  367. var currentToken = System.Text.Json.JsonSerializer.Deserialize<UserToken>(cachedJson);
  368. // 强制进入刷新逻辑
  369. await RefreshAndSaveTokenAsync(currentToken!, _sqlSugar);
  370. return true;
  371. }
  372. finally
  373. {
  374. userLock.Release();
  375. }
  376. }
  377. /// <summary>
  378. /// 静态 Token 提供者辅助类
  379. /// </summary>
  380. public class StaticTokenProvider : IAccessTokenProvider
  381. {
  382. private readonly string _token;
  383. public StaticTokenProvider(string token) => _token = token;
  384. public Task<string> GetAuthorizationTokenAsync(Uri uri, Dictionary<string, object>? context = null, CancellationToken ct = default) => Task.FromResult(_token);
  385. public AllowedHostsValidator AllowedHostsValidator { get; } = new();
  386. }
  387. #region 数据模型
  388. public class MailSendResult
  389. {
  390. public bool IsSuccess { get; set; }
  391. public string Message { get; set; } = string.Empty;
  392. public string? ErrorCode { get; set; } // Microsoft 提供的错误码
  393. public string Source => "Microsoft_Graph_API";
  394. }
  395. /// <summary>
  396. /// Hotmail 邮件服务 OAuth2 配置信息实体
  397. /// </summary>
  398. public class HotmailConfig
  399. {
  400. /// <summary>
  401. /// 用户唯一标识
  402. /// </summary>
  403. [JsonPropertyName("userId")]
  404. public int UserId { get; set; }
  405. /// <summary>
  406. /// 账号用户名
  407. /// </summary>
  408. [JsonPropertyName("userName")]
  409. public string UserName { get; set; }
  410. /// <summary>
  411. /// Azure AD 租户标识符 (Guid)
  412. /// </summary>
  413. [JsonPropertyName("tenantId")]
  414. public string TenantId { get; set; }
  415. /// <summary>
  416. /// 注册应用程序的客户端标识
  417. /// </summary>
  418. [JsonPropertyName("clientId")]
  419. public string ClientId { get; set; }
  420. /// <summary>
  421. /// 客户端密钥(敏感数据建议加密存储)
  422. /// </summary>
  423. [JsonPropertyName("clientSecret")]
  424. public string ClientSecret { get; set; }
  425. /// <summary>
  426. /// 租户类型(如 common, organizations 或具体域名)
  427. /// </summary>
  428. [JsonPropertyName("tenant")]
  429. public string Tenant { get; set; } = "common";
  430. /// <summary>
  431. /// OAuth2 回调重定向地址
  432. /// </summary>
  433. [JsonPropertyName("redirectUri")]
  434. public string RedirectUri { get; set; }
  435. }
  436. public class UserToken
  437. {
  438. public string Email { get; set; }
  439. public string AccessToken { get; set; }
  440. public string RefreshToken { get; set; }
  441. public DateTime ExpiresAt { get; set; }
  442. public string Source { get; set; }
  443. }
  444. /// <summary>
  445. /// 邮件请求对象
  446. /// </summary>
  447. public class MailDto
  448. {
  449. /// <summary>
  450. /// 邮件唯一标识符 (UID/Message-ID)
  451. /// </summary>
  452. [JsonPropertyName("messageId")]
  453. public string? MessageId { get; set; }
  454. /// <summary>
  455. /// 邮件主题
  456. /// </summary>
  457. [JsonPropertyName("subject")]
  458. public string? Subject { get; set; }
  459. /// <summary>
  460. /// 发件人地址 (e.g. "sender@example.com")
  461. /// </summary>
  462. [JsonPropertyName("from")]
  463. public string? From { get; set; }
  464. /// <summary>
  465. /// 收件人地址
  466. /// </summary>
  467. [JsonPropertyName("to")]
  468. public string? To { get; set; }
  469. /// <summary>
  470. /// 邮件正文内容 (HTML 或纯文本)
  471. /// </summary>
  472. [JsonPropertyName("content")]
  473. public string? Content { get; set; }
  474. /// <summary>
  475. /// 附件地址
  476. /// </summary>
  477. [JsonPropertyName("attachments")]
  478. public List<string> AttachmentPaths { get; set; } = new List<string>();
  479. /// <summary>
  480. /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性
  481. /// </summary>
  482. [JsonPropertyName("receivedTime")]
  483. public DateTimeOffset? ReceivedTime { get; set; }
  484. /// <summary>
  485. /// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData")
  486. /// </summary>
  487. [JsonPropertyName("source")]
  488. public string? Source { get; set; } = "Hotmail";
  489. }
  490. #endregion
  491. }
  492. }