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;
}
///
/// 统一获取 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)
{
// 线程安全的合并容器
var allMessages = new ConcurrentBag();
// 转换过滤条件 (建议预先处理)
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();
}
///
/// 指定账户发送邮件
///
public async Task 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
{
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}"
};
}
}
///
/// 获取邮箱配置信息 - single
///
///
public async Task GetUserMailConfig(int userId)
{
var allConfigs = await GetUserMailConfigListAsync();
if (allConfigs == null || !allConfigs.Any()) return null;
var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId);
return userConfig;
}
///
/// 获取邮箱配置信息 - ALL
///
///
public async Task?> GetUserMailConfigListAsync()
{
var remark = await _sqlSugar.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)
{
// 获取或创建针对该 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);
// 调试建议:记录刷新日志
// _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)
{
// 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
{
{ "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;
}
///
/// 静态 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; }
///
/// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性
///
[JsonPropertyName("receivedTime")]
public DateTimeOffset? ReceivedTime { get; set; }
///
/// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData")
///
[JsonPropertyName("source")]
public string? Source { get; set; } = "Hotmail";
}
#endregion
}
}