HotmailService.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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 StackExchange.Redis;
  7. using System.Text.Json;
  8. using System.Text.Json.Serialization;
  9. using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
  10. namespace OASystem.API.OAMethodLib.Hotmail
  11. {
  12. public class HotmailService
  13. {
  14. private readonly IHttpClientFactory _httpClientFactory;
  15. private readonly IConfiguration _config;
  16. private readonly SqlSugarClient _sqlSugar;
  17. private const string RedisKeyPrefix = "MailAlchemy:Token:";
  18. public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar)
  19. {
  20. _httpClientFactory = httpClientFactory;
  21. _config = config;
  22. _sqlSugar = sqlSugar;
  23. }
  24. /// <summary>
  25. /// hotmail 信息验证
  26. /// </summary>
  27. /// <param name="config"></param>
  28. /// <returns></returns>
  29. public (bool, string) ConfigVerify(HotmailConfig? config)
  30. {
  31. if (config == null) return (true, "当前用户未配置 hotmail 基础信息。");
  32. if (string.IsNullOrEmpty(config.UserName)) return (true, "当前用户未配置 hotmail 基础信息。");
  33. if (string.IsNullOrEmpty(config.ClientId)) return (true, "当前用户未配置 hotmail 租户标识符 (Guid)。");
  34. if (string.IsNullOrEmpty(config.TenantId)) return (true, "当前用户未配置 hotmail 应用程序的客户端标识。");
  35. if (string.IsNullOrEmpty(config.ClientSecret)) return (true, "当前用户未配置 hotmail 应用程序密钥。");
  36. if (string.IsNullOrEmpty(config.RedirectUri)) return (true, "当前用户未配置 hotmail OAuth2 回调重定向地址。");
  37. return (true, "");
  38. }
  39. /// <summary>
  40. /// Microsoft 鉴权预处理 - 生产增强版
  41. /// </summary>
  42. public async Task<(int status, string msg)> PrepareAuth(int userId)
  43. {
  44. // 1. 获取用户信息,支持空合并优化
  45. var userName = await _sqlSugar.Queryable<Sys_Users>()
  46. .Where(x => x.IsDel == 0 && x.Id == userId)
  47. .Select(x => x.CnName)
  48. .FirstAsync() ?? "未知用户";
  49. var userConfig = await GetUserMailConfig(userId);
  50. if (userConfig == null)
  51. return (-1, $"[{userName}] Hotmail 基础配置缺失");
  52. if (string.IsNullOrWhiteSpace(userConfig.UserName))
  53. return (-1, $"[{userName}] 未配置邮箱账号");
  54. // 2. 验证状态检查
  55. var redisKey = $"{RedisKeyPrefix}{userConfig.UserName.Trim()}";
  56. var cachedJson = await RedisRepository.RedisFactory.CreateRedisRepository().StringGetAsync<string>(redisKey);
  57. // 修正:已通过验证应返回 0
  58. if (!string.IsNullOrWhiteSpace(cachedJson))
  59. return (0, $"{userName} 已通过验证,无需重复操作");
  60. // 3. 授权参数深度净化
  61. var clientId = userConfig.ClientId?.Trim();
  62. var redirectUri = userConfig.RedirectUri?.Trim().Replace("\r", "").Replace("\n", ""); // 彻底剔除换行符
  63. //var redirectUri = "http://localhost:5256/api/microsoft/auth/callback";
  64. if (string.IsNullOrWhiteSpace(clientId))
  65. return (-1, $"[{userName}] 客户端 ID (ClientId) 未配置");
  66. if (string.IsNullOrWhiteSpace(redirectUri))
  67. return (-1, $"[{userName}] 回调地址 (RedirectUri) 未配置");
  68. // 4. 构建授权 URL
  69. const string authEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
  70. var queryParams = new Dictionary<string, string?>
  71. {
  72. { "client_id", clientId },
  73. { "response_type", "code" },
  74. { "redirect_uri", redirectUri },
  75. { "response_mode", "query" },
  76. { "scope", "offline_access Mail.Read Mail.Send User.Read" },
  77. { "state", userId.ToString() }
  78. //{ "state", Guid.NewGuid().ToString("N") }
  79. };
  80. // QueryHelpers 会处理 URL 编码,确保 RedirectUri 不会被二次破坏
  81. var authUrl = QueryHelpers.AddQueryString(authEndpoint, queryParams);
  82. return (1, authUrl);
  83. }
  84. /// <summary>
  85. /// 获取多个账户的合并收件箱 (并行处理)
  86. /// </summary>
  87. public async Task<List<MailDto>> GetMergedMessagesAsync(List<string> emails, DateTime cstStart, DateTime cstEnd)
  88. {
  89. var tasks = emails.Select(async email =>
  90. {
  91. try
  92. {
  93. var client = await GetClientAsync(email);
  94. // 转换北京时间为 UTC 字符串
  95. string startFilter = CommonFun.ToGraphUtcString(cstStart);
  96. string endFilter = CommonFun.ToGraphUtcString(cstEnd);
  97. var response = await client.Me.Messages.GetAsync(q =>
  98. {
  99. q.QueryParameters.Filter = $"receivedDateTime ge {startFilter} and receivedDateTime le {endFilter}";
  100. q.QueryParameters.Select = new[] { "id", "subject", "from", "toRecipients", "body", "receivedDateTime" };
  101. q.QueryParameters.Orderby = new[] { "receivedDateTime desc" };
  102. });
  103. return response?.Value?.Select(m => new MailDto
  104. {
  105. MessageId = m.Id,
  106. Subject = m.Subject,
  107. Content = m.Body?.Content,
  108. From = m.From?.EmailAddress?.Address,
  109. // 关键:将 Graph 返回的 UTC 时间转回北京时间给前端显示
  110. ReceivedTime = TimeZoneInfo.ConvertTimeFromUtc(m.ReceivedDateTime.Value.DateTime,TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")),
  111. Source = email
  112. }).ToList() ?? Enumerable.Empty<MailDto>();
  113. }
  114. catch (Exception ex)
  115. {
  116. Console.WriteLine($"[Error] Account {email}: {ex.Message}");
  117. return Enumerable.Empty<MailDto>();
  118. }
  119. });
  120. var results = await Task.WhenAll(tasks);
  121. return results.SelectMany(x => x).OrderByDescending(m => m.ReceivedTime).ToList();
  122. }
  123. /// <summary>
  124. /// 指定账户发送邮件
  125. /// </summary>
  126. public async Task<MailSendResult> SendMailAsync(string fromEmail, MailDto mail)
  127. {
  128. try
  129. {
  130. var client = await GetClientAsync(fromEmail);
  131. var requestBody = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody
  132. {
  133. Message = new Message
  134. {
  135. Subject = mail.Subject,
  136. Body = new ItemBody
  137. {
  138. Content = mail.Content,
  139. ContentType = BodyType.Html
  140. },
  141. ToRecipients = new List<Recipient>
  142. {
  143. new Recipient { EmailAddress = new EmailAddress { Address = mail.To } }
  144. }
  145. }
  146. };
  147. // 执行发送
  148. await client.Me.SendMail.PostAsync(requestBody);
  149. return new MailSendResult { IsSuccess = true, Message = "邮件发送成功!" };
  150. }
  151. catch (ODataError odataError) // 捕获 Graph 特有异常
  152. {
  153. // 常见的错误:ErrorInvalidUser, ErrorQuotaExceeded, ErrorMessageSubmissionBlocked
  154. var code = odataError.Error?.Code ?? "Unknown";
  155. var msg = odataError.Error?.Message ?? "微软 API 调用异常";
  156. return new MailSendResult
  157. {
  158. IsSuccess = false,
  159. ErrorCode = code,
  160. Message = $"发送失败: {msg}"
  161. };
  162. }
  163. catch (Exception ex)
  164. {
  165. return new MailSendResult
  166. {
  167. IsSuccess = false,
  168. ErrorCode = "InternalError",
  169. Message = $"系统内部错误: {ex.Message}"
  170. };
  171. }
  172. }
  173. /// <summary>
  174. /// 获取邮箱配置信息 - single
  175. /// </summary>
  176. /// <returns></returns>
  177. public async Task<HotmailConfig?> GetUserMailConfig(int userId)
  178. {
  179. var allConfigs = await GetUserMailConfigListAsync();
  180. if (allConfigs == null || !allConfigs.Any()) return null;
  181. var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId);
  182. return userConfig;
  183. }
  184. /// <summary>
  185. /// 获取邮箱配置信息 - ALL
  186. /// </summary>
  187. /// <returns></returns>
  188. public async Task<List<HotmailConfig>?> GetUserMailConfigListAsync()
  189. {
  190. var remark = await _sqlSugar.Queryable<Sys_SetData>()
  191. .Where(x => x.IsDel == 0 && x.Id == 1555 && x.STid == 137)
  192. .Select(x => x.Remark)
  193. .FirstAsync();
  194. if (string.IsNullOrWhiteSpace(remark)) return null;
  195. try
  196. {
  197. var allConfigs = JsonConvert.DeserializeObject<List<HotmailConfig>>(remark);
  198. return allConfigs;
  199. }
  200. catch (Exception)
  201. {
  202. return null;
  203. }
  204. }
  205. /// <summary>
  206. /// 获取 Graph 客户端,处理 Token 自动刷新
  207. /// </summary>
  208. private async Task<GraphServiceClient> GetClientAsync(string email)
  209. {
  210. var cachedJson = await RedisRepository.RedisFactory.CreateRedisRepository().StringGetAsync<string>($"{RedisKeyPrefix}{email}");
  211. if (string.IsNullOrEmpty(cachedJson)) throw new UnauthorizedAccessException($"Account {email} not initialized in Redis.");
  212. var token = System.Text.Json.JsonSerializer.Deserialize<UserToken>(cachedJson!)!;
  213. // 令牌过期预校验 (提前 5 分钟)
  214. if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5))
  215. {
  216. token = await RefreshAndSaveTokenAsync(token);
  217. }
  218. var authProvider = new BaseBearerTokenAuthenticationProvider(new StaticTokenProvider(token.AccessToken));
  219. return new GraphServiceClient(authProvider);
  220. }
  221. public async Task<UserToken> RefreshAndSaveTokenAsync(UserToken oldToken)
  222. {
  223. var httpClient = _httpClientFactory.CreateClient();
  224. var kvp = new Dictionary<string, string> {
  225. { "client_id", _config["AzureAd:ClientId"] },
  226. { "client_secret", _config["AzureAd:ClientSecret"] },
  227. { "grant_type", "refresh_token" },
  228. { "refresh_token", oldToken.RefreshToken },
  229. { "scope", "offline_access Mail.Read Mail.Send" }
  230. };
  231. var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", new FormUrlEncodedContent(kvp));
  232. if (!response.IsSuccessStatusCode) throw new Exception("Token refresh failed.");
  233. using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
  234. var root = doc.RootElement;
  235. var newToken = new UserToken
  236. {
  237. Email = oldToken.Email,
  238. AccessToken = root.GetProperty("access_token").GetString()!,
  239. RefreshToken = root.GetProperty("refresh_token").GetString()!,
  240. ExpiresAt = DateTime.UtcNow.AddSeconds(root.GetProperty("expires_in").GetInt32())
  241. };
  242. // 存入 Redis,持久化 90 天(RefreshToken 的典型寿命)
  243. await RedisRepository.RedisFactory.CreateRedisRepository().StringSetAsync<string>($"{RedisKeyPrefix}{oldToken.Email}", System.Text.Json.JsonSerializer.Serialize(newToken), TimeSpan.FromDays(90));
  244. return newToken;
  245. }
  246. /// <summary>
  247. /// 静态 Token 提供者辅助类
  248. /// </summary>
  249. public class StaticTokenProvider : IAccessTokenProvider
  250. {
  251. private readonly string _token;
  252. public StaticTokenProvider(string token) => _token = token;
  253. public Task<string> GetAuthorizationTokenAsync(Uri uri, Dictionary<string, object>? context = null, CancellationToken ct = default) => Task.FromResult(_token);
  254. public AllowedHostsValidator AllowedHostsValidator { get; } = new();
  255. }
  256. #region 数据模型
  257. public class MailSendResult
  258. {
  259. public bool IsSuccess { get; set; }
  260. public string Message { get; set; } = string.Empty;
  261. public string? ErrorCode { get; set; } // Microsoft 提供的错误码
  262. public string Source => "Microsoft_Graph_API";
  263. }
  264. /// <summary>
  265. /// Hotmail 邮件服务 OAuth2 配置信息实体
  266. /// </summary>
  267. public class HotmailConfig
  268. {
  269. /// <summary>
  270. /// 用户唯一标识
  271. /// </summary>
  272. [JsonPropertyName("userId")]
  273. public int UserId { get; set; }
  274. /// <summary>
  275. /// 账号用户名
  276. /// </summary>
  277. [JsonPropertyName("userName")]
  278. public string UserName { get; set; }
  279. /// <summary>
  280. /// Azure AD 租户标识符 (Guid)
  281. /// </summary>
  282. [JsonPropertyName("tenantId")]
  283. public string TenantId { get; set; }
  284. /// <summary>
  285. /// 注册应用程序的客户端标识
  286. /// </summary>
  287. [JsonPropertyName("clientId")]
  288. public string ClientId { get; set; }
  289. /// <summary>
  290. /// 客户端密钥(敏感数据建议加密存储)
  291. /// </summary>
  292. [JsonPropertyName("clientSecret")]
  293. public string ClientSecret { get; set; }
  294. /// <summary>
  295. /// 租户类型(如 common, organizations 或具体域名)
  296. /// </summary>
  297. [JsonPropertyName("tenant")]
  298. public string Tenant { get; set; } = "common";
  299. /// <summary>
  300. /// OAuth2 回调重定向地址
  301. /// </summary>
  302. [JsonPropertyName("redirectUri")]
  303. public string RedirectUri { get; set; }
  304. }
  305. public class UserToken
  306. {
  307. public string Email { get; set; }
  308. public string AccessToken { get; set; }
  309. public string RefreshToken { get; set; }
  310. public DateTime ExpiresAt { get; set; }
  311. }
  312. /// <summary>
  313. /// 邮件请求对象
  314. /// </summary>
  315. public class MailDto
  316. {
  317. /// <summary>
  318. /// 邮件唯一标识符 (UID/Message-ID)
  319. /// </summary>
  320. [JsonPropertyName("messageId")]
  321. public string? MessageId { get; set; }
  322. /// <summary>
  323. /// 邮件主题
  324. /// </summary>
  325. [JsonPropertyName("subject")]
  326. public string? Subject { get; set; }
  327. /// <summary>
  328. /// 发件人地址 (e.g. "sender@example.com")
  329. /// </summary>
  330. [JsonPropertyName("from")]
  331. public string? From { get; set; }
  332. /// <summary>
  333. /// 收件人地址
  334. /// </summary>
  335. [JsonPropertyName("to")]
  336. public string? To { get; set; }
  337. /// <summary>
  338. /// 邮件正文内容 (HTML 或纯文本)
  339. /// </summary>
  340. [JsonPropertyName("content")]
  341. public string? Content { get; set; }
  342. /// <summary>
  343. /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性
  344. /// </summary>
  345. [JsonPropertyName("receivedTime")]
  346. public DateTimeOffset? ReceivedTime { get; set; }
  347. /// <summary>
  348. /// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData")
  349. /// </summary>
  350. [JsonPropertyName("source")]
  351. public string? Source { get; set; } = "Hotmail";
  352. }
  353. #endregion
  354. }
  355. }