|
|
@@ -3,10 +3,10 @@ using Microsoft.Graph;
|
|
|
using Microsoft.Graph.Models;
|
|
|
using Microsoft.Graph.Models.ODataErrors;
|
|
|
using Microsoft.Kiota.Abstractions.Authentication;
|
|
|
-using StackExchange.Redis;
|
|
|
+using System.Collections.Concurrent;
|
|
|
using System.Text.Json;
|
|
|
using System.Text.Json.Serialization;
|
|
|
-using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
|
|
|
+using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
|
|
|
|
namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
{
|
|
|
@@ -15,7 +15,7 @@ namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
|
private readonly IConfiguration _config;
|
|
|
private readonly SqlSugarClient _sqlSugar;
|
|
|
- private const string RedisKeyPrefix = "MailAlchemy:Token:";
|
|
|
+ public const string RedisKeyPrefix = "MailAlchemy:Token:";
|
|
|
|
|
|
public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar)
|
|
|
{
|
|
|
@@ -24,6 +24,11 @@ namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
_sqlSugar = sqlSugar;
|
|
|
}
|
|
|
|
|
|
+ /// <summary>
|
|
|
+ /// 统一获取 Redis Key
|
|
|
+ /// </summary>
|
|
|
+ public static string GetRedisKey(string email) => $"{RedisKeyPrefix}{email.Trim().ToLower()}";
|
|
|
+
|
|
|
/// <summary>
|
|
|
/// hotmail 信息验证
|
|
|
/// </summary>
|
|
|
@@ -46,40 +51,27 @@ namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
/// </summary>
|
|
|
public async Task<(int status, string msg)> PrepareAuth(int userId)
|
|
|
{
|
|
|
- // 1. 获取用户信息,支持空合并优化
|
|
|
- var userName = await _sqlSugar.Queryable<Sys_Users>()
|
|
|
- .Where(x => x.IsDel == 0 && x.Id == userId)
|
|
|
- .Select(x => x.CnName)
|
|
|
- .FirstAsync() ?? "未知用户";
|
|
|
-
|
|
|
+ // 1. 基础配置校验 (SqlSugar 优化)
|
|
|
var userConfig = await GetUserMailConfig(userId);
|
|
|
- if (userConfig == null)
|
|
|
- return (-1, $"[{userName}] Hotmail 基础配置缺失");
|
|
|
-
|
|
|
- if (string.IsNullOrWhiteSpace(userConfig.UserName))
|
|
|
- return (-1, $"[{userName}] 未配置邮箱账号");
|
|
|
+ if (userConfig == null || string.IsNullOrWhiteSpace(userConfig.UserName))
|
|
|
+ return (-1, "账号基础配置缺失");
|
|
|
|
|
|
- // 2. 验证状态检查
|
|
|
- var redisKey = $"{RedisKeyPrefix}{userConfig.UserName.Trim()}";
|
|
|
- var cachedJson = await RedisRepository.RedisFactory.CreateRedisRepository().StringGetAsync<string>(redisKey);
|
|
|
+ // 2. 状态检查 (Redis)
|
|
|
+ var redisKey = GetRedisKey(userConfig.UserName);
|
|
|
+ var repo = RedisRepository.RedisFactory.CreateRedisRepository();
|
|
|
+ var cachedJson = await repo.StringGetAsync<string>(redisKey);
|
|
|
|
|
|
- // 修正:已通过验证应返回 0
|
|
|
if (!string.IsNullOrWhiteSpace(cachedJson))
|
|
|
- return (0, $"{userName} 已通过验证,无需重复操作");
|
|
|
+ return (0, "已通过验证,无需重复操作");
|
|
|
|
|
|
- // 3. 授权参数深度净化
|
|
|
+ // 3. 参数净化与严谨性
|
|
|
var clientId = userConfig.ClientId?.Trim();
|
|
|
- var redirectUri = userConfig.RedirectUri?.Trim().Replace("\r", "").Replace("\n", ""); // 彻底剔除换行符
|
|
|
-
|
|
|
- //var redirectUri = "http://localhost:5256/api/microsoft/auth/callback";
|
|
|
+ var redirectUri = userConfig.RedirectUri?.Trim().Split('\r', '\n')[0]; // 取第一行并修剪
|
|
|
|
|
|
- if (string.IsNullOrWhiteSpace(clientId))
|
|
|
- return (-1, $"[{userName}] 客户端 ID (ClientId) 未配置");
|
|
|
+ if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri))
|
|
|
+ return (-1, "ClientId 或 RedirectUri 配置无效");
|
|
|
|
|
|
- if (string.IsNullOrWhiteSpace(redirectUri))
|
|
|
- return (-1, $"[{userName}] 回调地址 (RedirectUri) 未配置");
|
|
|
-
|
|
|
- // 4. 构建授权 URL
|
|
|
+ // 4. 构建长效授权 URL
|
|
|
const string authEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
|
|
|
|
var queryParams = new Dictionary<string, string?>
|
|
|
@@ -88,59 +80,76 @@ namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
{ "response_type", "code" },
|
|
|
{ "redirect_uri", redirectUri },
|
|
|
{ "response_mode", "query" },
|
|
|
- { "scope", "offline_access Mail.Read Mail.Send User.Read" },
|
|
|
- { "state", userId.ToString() }
|
|
|
- //{ "state", Guid.NewGuid().ToString("N") }
|
|
|
+ // 核心:必须包含 offline_access 且建议加上 openid
|
|
|
+ { "scope", "openid offline_access Mail.ReadWrite Mail.Send User.Read" },
|
|
|
+ { "state", userId.ToString() }, // 简单场景使用 userId,安全场景建议使用加密 Hash
|
|
|
+ { "prompt", "consent" } // 关键:确保触发长效令牌授权
|
|
|
};
|
|
|
|
|
|
- // QueryHelpers 会处理 URL 编码,确保 RedirectUri 不会被二次破坏
|
|
|
var authUrl = QueryHelpers.AddQueryString(authEndpoint, queryParams);
|
|
|
|
|
|
+ // 准则 4a: 直接返回结果
|
|
|
return (1, authUrl);
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// 获取多个账户的合并收件箱 (并行处理)
|
|
|
- /// </summary>
|
|
|
public async Task<List<MailDto>> GetMergedMessagesAsync(List<string> emails, DateTime cstStart, DateTime cstEnd)
|
|
|
{
|
|
|
- var tasks = emails.Select(async email =>
|
|
|
+ // 线程安全的合并容器
|
|
|
+ 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);
|
|
|
-
|
|
|
- // 转换北京时间为 UTC 字符串
|
|
|
- string startFilter = CommonFun.ToGraphUtcString(cstStart);
|
|
|
- string endFilter = CommonFun.ToGraphUtcString(cstEnd);
|
|
|
-
|
|
|
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", "toRecipients", "body", "receivedDateTime" };
|
|
|
+ q.QueryParameters.Select = new[] { "id", "subject", "from", "bodyPreview", "receivedDateTime" };
|
|
|
q.QueryParameters.Orderby = new[] { "receivedDateTime desc" };
|
|
|
- });
|
|
|
+ q.QueryParameters.Top = 50; // 生产环境建议增加 Top 限制
|
|
|
+ }, ct);
|
|
|
|
|
|
- return response?.Value?.Select(m => new MailDto
|
|
|
+ if (response?.Value != null)
|
|
|
{
|
|
|
- MessageId = m.Id,
|
|
|
- Subject = m.Subject,
|
|
|
- Content = m.Body?.Content,
|
|
|
- From = m.From?.EmailAddress?.Address,
|
|
|
- // 关键:将 Graph 返回的 UTC 时间转回北京时间给前端显示
|
|
|
- ReceivedTime = TimeZoneInfo.ConvertTimeFromUtc(m.ReceivedDateTime.Value.DateTime,TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")),
|
|
|
- Source = email
|
|
|
- }).ToList() ?? Enumerable.Empty<MailDto>();
|
|
|
+ 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)
|
|
|
{
|
|
|
- Console.WriteLine($"[Error] Account {email}: {ex.Message}");
|
|
|
- return Enumerable.Empty<MailDto>();
|
|
|
+ // 生产环境应接入 ILogger
|
|
|
+ //_logger.LogError(ex, "Failed to fetch mail for {Email}", email);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- var results = await Task.WhenAll(tasks);
|
|
|
- return results.SelectMany(x => x).OrderByDescending(m => m.ReceivedTime).ToList();
|
|
|
+ // 最终排序并输出
|
|
|
+ return allMessages.OrderByDescending(m => m.ReceivedTime).ToList();
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
@@ -202,7 +211,7 @@ namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
/// 获取邮箱配置信息 - single
|
|
|
/// </summary>
|
|
|
/// <returns></returns>
|
|
|
- public async Task<HotmailConfig?> GetUserMailConfig(int userId)
|
|
|
+ public async Task<HotmailConfig?> GetUserMailConfig(int userId)
|
|
|
{
|
|
|
var allConfigs = await GetUserMailConfigListAsync();
|
|
|
|
|
|
@@ -237,52 +246,108 @@ namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
- /// 获取 Graph 客户端,处理 Token 自动刷新
|
|
|
+ /// 线程锁
|
|
|
+ /// </summary>
|
|
|
+ private static readonly ConcurrentDictionary<string, SemaphoreSlim> _userLocks = new ConcurrentDictionary<string, SemaphoreSlim>();
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 获取 Graph 客户端,处理 Token 自动刷新 (线程安全版)
|
|
|
/// </summary>
|
|
|
private async Task<GraphServiceClient> GetClientAsync(string email)
|
|
|
{
|
|
|
- var cachedJson = await RedisRepository.RedisFactory.CreateRedisRepository().StringGetAsync<string>($"{RedisKeyPrefix}{email}");
|
|
|
- if (string.IsNullOrEmpty(cachedJson)) throw new UnauthorizedAccessException($"Account {email} not initialized in Redis.");
|
|
|
+ // 获取或创建针对该 Email 的独立信号量锁
|
|
|
+ var userLock = _userLocks.GetOrAdd(email, _ => new SemaphoreSlim(1, 1));
|
|
|
|
|
|
- var token = System.Text.Json.JsonSerializer.Deserialize<UserToken>(cachedJson!)!;
|
|
|
+ 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);
|
|
|
+ }
|
|
|
|
|
|
- // 令牌过期预校验 (提前 5 分钟)
|
|
|
- if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5))
|
|
|
+ // 3. 构造认证提供者 (Scoped 局部化)
|
|
|
+ // 使用 StaticTokenProvider 封装当前的 AccessToken
|
|
|
+ var tokenProvider = new StaticTokenProvider(token.AccessToken);
|
|
|
+ var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
|
|
|
+
|
|
|
+ // 4. 返回全新的客户端实例,确保 RequestAdapter 隔离
|
|
|
+ return new GraphServiceClient(authProvider);
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
{
|
|
|
- token = await RefreshAndSaveTokenAsync(token);
|
|
|
+ // _logger.LogError(ex, "GetClientAsync failed for {Email}", email);
|
|
|
+ throw;
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ userLock.Release(); // 必须在 finally 中释放锁
|
|
|
}
|
|
|
-
|
|
|
- var authProvider = new BaseBearerTokenAuthenticationProvider(new StaticTokenProvider(token.AccessToken));
|
|
|
- return new GraphServiceClient(authProvider);
|
|
|
}
|
|
|
|
|
|
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", _config["AzureAd:ClientId"] },
|
|
|
- { "client_secret", _config["AzureAd:ClientSecret"] },
|
|
|
- { "grant_type", "refresh_token" },
|
|
|
- { "refresh_token", oldToken.RefreshToken },
|
|
|
- { "scope", "offline_access Mail.Read Mail.Send" }
|
|
|
- };
|
|
|
+ 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) throw new Exception("Token refresh failed.");
|
|
|
+
|
|
|
+ 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 = root.GetProperty("refresh_token").GetString()!,
|
|
|
- ExpiresAt = DateTime.UtcNow.AddSeconds(root.GetProperty("expires_in").GetInt32())
|
|
|
+ // 关键:微软可能会滚动更新 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"
|
|
|
};
|
|
|
|
|
|
- // 存入 Redis,持久化 90 天(RefreshToken 的典型寿命)
|
|
|
- await RedisRepository.RedisFactory.CreateRedisRepository().StringSetAsync<string>($"{RedisKeyPrefix}{oldToken.Email}", System.Text.Json.JsonSerializer.Serialize(newToken), TimeSpan.FromDays(90));
|
|
|
+ // 4. 同步更新 Redis (保持 90 天长效)
|
|
|
+ var redisKey = GetRedisKey(oldToken.Email);
|
|
|
+ await RedisRepository.RedisFactory.CreateRedisRepository()
|
|
|
+ .StringSetAsync(redisKey, JsonSerializer.Serialize(newToken), TimeSpan.FromDays(90));
|
|
|
+
|
|
|
return newToken;
|
|
|
}
|
|
|
|
|
|
@@ -361,6 +426,8 @@ namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
public string AccessToken { get; set; }
|
|
|
public string RefreshToken { get; set; }
|
|
|
public DateTime ExpiresAt { get; set; }
|
|
|
+
|
|
|
+ public string Source { get; set; }
|
|
|
}
|
|
|
|
|
|
/// <summary>
|