Przeglądaj źródła

1. 处理 microsoft 长命令缓存失效问题
2. 接收邮箱信息汇总,发送至企业微信

Lyyyi 1 tydzień temu
rodzic
commit
c1601e9364

+ 83 - 54
OASystem/OASystem.Api/Controllers/AuthController.cs

@@ -489,7 +489,7 @@ namespace OASystem.API.Controllers
         }
 
         /// <summary>
-        /// microsoft 回调地址
+        /// microsoft auth 回调
         /// </summary>
         /// <param name="code"></param>
         /// <param name="state"></param>
@@ -499,81 +499,110 @@ namespace OASystem.API.Controllers
         {
             if (string.IsNullOrEmpty(code)) return BadRequest("授权码无效");
 
-            // 1. 从 state 中解析出真正的 userId
-            if (!int.TryParse(state, out int userId))
-            {
-                return BadRequest("非法的 state 标识");
-            }
+            // 1. 状态与配置校验
+            if (!int.TryParse(state, out int userId)) return BadRequest("非法的 state 标识");
 
             var config = await _hotmailService.GetUserMailConfig(userId);
-            if (config == null)
-            {
-                return BadRequest("state标识无效");
-            }
+            if (config == null) return BadRequest("对应配置信息不存在");
 
-            // 1. 换取令牌
-            var httpClient = _httpClientFactory.CreateClient();
-            var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
+            try
             {
-                { "client_id",config.ClientId },
-                { "client_secret", config.ClientSecret },
-                { "code", code },
-                { "redirect_uri", config.RedirectUri },
-                { "grant_type", "authorization_code" }
-            });
+                // 2. 换取令牌 (使用严格的参数清洗)
+                var httpClient = _httpClientFactory.CreateClient();
+                var tokenRequest = new Dictionary<string, string>
+                {
+                    { "client_id", config.ClientId.Trim() },
+                    { "client_secret", config.ClientSecret.Trim() },
+                    { "code", code },
+                    { "redirect_uri", config.RedirectUri.Trim() }, // 确保与 PrepareAuth 阶段完全一致
+                    { "grant_type", "authorization_code" },
+                    // 换取时再次明确 scope 
+                    { "scope", "offline_access Mail.Read Mail.Send User.Read" }
+                };
 
-            var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", tokenRequest);
-            var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+                var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", new FormUrlEncodedContent(tokenRequest));
+                var responseContent = await response.Content.ReadAsStringAsync();
 
-            if (!response.IsSuccessStatusCode) return BadRequest(json.RootElement.ToString());
+                if (!response.IsSuccessStatusCode) return BadRequest($"令牌交换失败: {responseContent}");
 
-            var root = json.RootElement;
-            var accessToken = root.GetProperty("access_token").GetString()!;
-            var refreshToken = root.GetProperty("refresh_token").GetString()!;
-            var expiresIn = root.GetProperty("expires_in").GetInt32();
+                var root = JsonDocument.Parse(responseContent).RootElement;
 
-            // 2. 自动识别账户身份 【核心重构】:不再手动解析 JWT,而是请求 Graph 的 /me 接口
-            string userEmail = await GetEmailFromGraphApiAsync(accessToken);
+                // 3. 提取令牌 (准则:确保 refresh_token 存在)
+                var accessToken = root.GetProperty("access_token").GetString()!;
+                var refreshToken = root.TryGetProperty("refresh_token", out var rt) ? rt.GetString()! : null;
+                var expiresIn = root.GetProperty("expires_in").GetInt32();
 
-            // 3. 构造并存入 Redis
-            var userToken = new UserToken
-            {
-                Email = userEmail,
-                AccessToken = accessToken,
-                RefreshToken = refreshToken,
-                ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn)
-            };
+                if (string.IsNullOrEmpty(refreshToken))
+                    return BadRequest("未能获取长效刷新令牌,请检查 offline_access 权限。");
 
-            // 存入 Redis
-            var redisKey = $"MailAlchemy:Token:{userEmail}";
-            await RedisRepository.RedisFactory.CreateRedisRepository().StringSetAsync<string>(redisKey, System.Text.Json.JsonSerializer.Serialize(userToken), TimeSpan.FromDays(90));
+                // 4. 获取用户信息
+                string userEmail = await GetEmailFromGraphApiAsync(accessToken);
 
-            return Ok(new
+                // 5. 缓存 (准则 2 & 3)
+                var userToken = new UserToken
+                {
+                    Email = userEmail,
+                    AccessToken = accessToken,
+                    RefreshToken = refreshToken,
+                    ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn),
+                    Source = "Microsoft_Graph" // 准则 3: 增加标识
+                };
+
+                // Redis 缓存 (用于 API 快速调用)
+                var redisKey = HotmailService.GetRedisKey(userEmail);
+                await RedisRepository.RedisFactory.CreateRedisRepository()
+                    .StringSetAsync(redisKey, userToken, TimeSpan.FromDays(90));
+
+                // 6. 返回结果 
+                return Ok(new
+                {
+                    status = "Success",
+                    account = userEmail,
+                    expiresInSeconds = expiresIn,
+                    source = userToken.Source
+                });
+            }
+            catch (Exception ex)
             {
-                status = "Success",
-                account = userEmail,
-                message = "该个人账户已成功集成并启用分布式存储"
-            });
+                // _logger.LogError(ex, "Callback processing failed");
+                return StatusCode(500, "回调失败,请检查控制台日志");
+            }
         }
 
         private async Task<string> GetEmailFromGraphApiAsync(string accessToken)
         {
-            var httpClient = _httpClientFactory.CreateClient();
-            // 使用 AccessToken 调用 Graph API 的个人信息接口
-            httpClient.DefaultRequestHeaders.Authorization =
-                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
+            // 1. 使用 HttpClientFactory 获取预设或独立的 Client
+            var httpClient = _httpClientFactory.CreateClient("MicrosoftGraph");
 
-            var response = await httpClient.GetAsync("https://graph.microsoft.com/v1.0/me");
+            // 2. 构造请求头
+            var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
+            request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
+
+            // 3. 发送请求并确保成功
+            var response = await httpClient.SendAsync(request);
             if (!response.IsSuccessStatusCode)
-                throw new Exception("无法通过 Graph API 获取用户信息");
+            {
+                var errorContent = await response.Content.ReadAsStringAsync();
+                throw new Exception($"Graph API 身份验证失败: {response.StatusCode}, {errorContent}");
+            }
 
+            // 4. 安全解析 JSON
             using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
             var root = doc.RootElement;
 
-            // 个人账户优先取 mail,如果没有则取 userPrincipalName
-            return root.GetProperty("mail").GetString()
-                ?? root.GetProperty("userPrincipalName").GetString()
-                ?? throw new Exception("未能获取有效的 Email 地址");
+            // 优先序:mail (邮箱) > userPrincipalName (登录名)
+            string? email = null;
+
+            if (root.TryGetProperty("mail", out var mailProp))
+                email = mailProp.GetString();
+
+            if (string.IsNullOrEmpty(email) && root.TryGetProperty("userPrincipalName", out var upnProp))
+                email = upnProp.GetString();
+
+            if (string.IsNullOrWhiteSpace(email))
+                throw new Exception("Graph API 返回结果中缺失有效的身份标识 (mail/upn)");
+
+            return email.Trim().ToLower(); // 准则:归一化存储,避免大小写导致的缓存失效
         }
 
         #endregion

+ 2 - 2
OASystem/OASystem.Api/Controllers/ResourceController.cs

@@ -2773,7 +2773,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
         }
 
         /// <summary>
-        /// 炼金辅助:清洗并解析 AI 返回的 JSON 块
+        /// 清洗并解析 AI 返回的 JSON 块
         /// </summary>
         private static T? CleanAndParseJson<T>(string rawResponse)
         {
@@ -3212,7 +3212,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             // 筛选出待转正的 AI 数据 (使用 HashSet 优化匹配)
             var guidSet = dto.Guids.ToHashSet();
             var targetAiDetails = dataInfo.AiCrawledDetails
-                .Where(x => x.Source == 1 && guidSet.Contains(x.Guid))
+                .Where(x => x.Source != 0 && guidSet.Contains(x.Guid))
                 .ToList();
 
             if (!targetAiDetails.Any())

+ 147 - 80
OASystem/OASystem.Api/OAMethodLib/Hotmail/HotmailService.cs

@@ -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>

+ 59 - 39
OASystem/OASystem.Api/OAMethodLib/Quartz/Business/ProcessAndNotifySummary.cs

@@ -34,8 +34,9 @@ namespace OASystem.API.OAMethodLib.Quartz.Business
             var cstZone = CommonFun.GetCstZone();
             var nowInCst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, cstZone);
             var yesterdayStart = nowInCst.Date.AddDays(-1);
-            var yesterdayEnd = yesterdayStart.AddDays(3).AddTicks(-1);
+            var yesterdayEnd = yesterdayStart.AddDays(1).AddTicks(-1);
 
+            // 获取邮件信息
             var emailInfos = await _hotmailService.GetMergedMessagesAsync(hotmails, yesterdayStart, yesterdayEnd);
 
             // 处理无邮件情况
@@ -45,13 +46,13 @@ namespace OASystem.API.OAMethodLib.Quartz.Business
                 return;
             }
 
-            // 1. 预处理:限制每封邮件正文长度,防止 Token 溢出
-            foreach (var mail in emailInfos)
-            {
-                mail.Content = CleanHtmlToPlainText(mail.Content);
-            }
+            //// 预处理:限制每封邮件正文长度,防止 Token 溢出
+            //foreach (var mail in emailInfos)
+            //{
+            //    mail.Content = CleanHtmlToPlainText(mail.Content);
+            //}
 
-            // 2. 调用 AI
+            // 调用 AI
             var question = BuildMailSummaryPrompt(emailInfos);
             var res = await _deepSeekService.ChatAsync(question);
 
@@ -71,26 +72,43 @@ namespace OASystem.API.OAMethodLib.Quartz.Business
                         var qwEmail = users.FirstOrDefault(x => x.Id == hotmailConfig.UserId)?.Email;
                         if (string.IsNullOrEmpty(qwEmail)) continue;
 
-                        if (hotmailConfig.UserName.Equals("925554512@qq.com"))
+                        if (hotmailConfig.UserName.Equals("925554512@qq.com") || hotmailConfig.UserName.Equals("Roy.Lei.Atom@hotmail.com"))
                         {
-                            hotmailConfig.UserName = "Roy.Lei.Atom@hotmail.com";
+
+                            if (aiSummaryResults.Any(x => x.Recipient.Equals("925554512@qq.com")))
+                            {
+                                hotmailConfig.UserName = "925554512@qq.com";
+                            }
+
+                            if (aiSummaryResults.Any(x => x.Recipient.Equals("Roy.Lei.Atom@hotmail.com")))
+                            {
+                                hotmailConfig.UserName = "Roy.Lei.Atom@hotmail.com";
+                            }
                         }
 
                         // 获取 AI 为该账号生成的摘要
                         var summary = aiSummaryResults?.FirstOrDefault(x => x.Recipient.Equals(hotmailConfig.UserName, StringComparison.OrdinalIgnoreCase));
 
                         string finalSubject = $"{DateTime.Now:yyyy-MM-dd} - 邮件汇总";
-                        string finalBody = "AI 未能成功生成今日摘要。";
+                        string finalBody = "未能获取到hotmail邮件。";
 
                         if (summary != null)
                         {
                             finalSubject = $"[AI摘要] {summary.EmailSubject}";
-                            finalBody = summary.HtmlBody;
+                            finalBody = summary.TextBody;
                         }
 
+                        // 测试阶段默认发送在我的邮箱
+                        string defualtEmail1 = "johnny.yang@pan-american-intl.com";
+                        string defualtEmail2 = "Roy.lei@pan-american-intl.com";
+
                         await _qiYeWeChatApiService.EmailSendAsync(new EmailRequestDto
                         {
-                            ToEmails = new List<string> { qwEmail },
+                            ToEmails = new List<string> { 
+                                qwEmail,
+                                defualtEmail1,
+                                defualtEmail2
+                            },
                             Subject = finalSubject,
                             Body = finalBody,
                         });
@@ -103,7 +121,6 @@ namespace OASystem.API.OAMethodLib.Quartz.Business
             }
         }
 
-
         /// <summary>
         /// 纯正则实现:剔除 HTML 标签、样式和脚本,保留核心文本
         /// </summary>
@@ -127,39 +144,40 @@ namespace OASystem.API.OAMethodLib.Quartz.Business
             return html.Trim();
         }
 
-
         public static string BuildMailSummaryPrompt(List<MailDto> mailList)
         {
             var rawDataJson = System.Text.Json.JsonSerializer.Serialize(mailList, new JsonSerializerOptions { WriteIndented = false });
 
             return $@"
-# Role: 高级邮件情报官 (Senior Email Intelligence Officer)
+# Role: .NET 邮件情报分析引擎 (JSON-ONLY Mode)
 
 ## Task
-分析提供的 {mailList.Count} 封原始邮件,排除垃圾内容,并为每个收件人生成 HTML 格式的每日摘要报告
-
-## Rule & Logic
-1. **Filtering**: 严禁包含:自动订阅、社交媒体动态、验证码、退订链接邮件
-2. **Analysis**: 对同一收件人的邮件按业务逻辑归类(如:财务类、技术类、会议类)
-3. **HTML Requirements**: 
-   - 必须使用 `border-collapse: collapse; width: 100%;` 的表格
-   - 主题色使用蓝色 (#2563eb)
-   - 包含三部分:【核心概览】、【详情表格 (列: 时间, 主题, 来源, 摘要)】、【待办建议 (Action Items)】。
-   - 所有 CSS 必须 Inline Style
-
-## HTML Format (Strict)
-- 仅允许使用: <h3> (标题), <p> (段落), <ul>/<li> (列表), <strong> (加粗), <hr> (分割线)。
-- 禁止使用: <table>, <div>, <span>, class, id。
-- 样式: 仅在 <h3> 中使用 style=""color:#2563eb""。
-
-## Constraints
-- 输出必须是合法的 JSON 数组,严禁任何前言或后缀文字。
-- 属性名:Recipient, EmailSubject, HtmlBody。
-- HtmlBody 必须是完整的 HTML 字符串,注意 JSON 内部引号转义。
-
-## Input Data (JSON)
+解析以下 `rawDataJson` 数据,按 `Recipient` (收件人) 分组并生成深度分析简报
+
+## Constraints (Strict)
+1. **Output Format**: 只输出标准的 JSON 数组代码块。严禁包含任何开场白、结尾问候、Markdown 解释文字或非 JSON 字符
+2. **HTML Rule**: `TextBody` 字段内仅允许使用 `<strong>` 和 `<br />`。严禁使用 `\n`、`<div>` 或其他标签
+3. **Naming Convention**: 
+   - 字典 Key/属性名: 必须使用 **PascalCase** (如: Recipient, EmailSubject, TextBody)
+   - 内部逻辑变量: 使用 **camelCase**
+4. **Logic**: 
+   - 按请求中的 `to` 字段进行分组
+   - 分析每组邮件的业务关联性,生成 [当日概览]、[详情摘要]、[分析结论]。
+
+## Output Schema
+[
+  {{
+    ""Recipient"": ""string"",
+    ""EmailSubject"": ""每日情报分析报告"",
+    ""TextBody"": ""<strong>[收件人:...]</strong><br /><br /><strong>[核心概览]</strong><br />...分析内容...<br /><br /><strong>[分析结论]</strong><br />...""
+  }}
+]
+
+## Input Data
 {rawDataJson}
-";
+
+## Execution
+Now, output the JSON array based on the logic above. No prose, no chat, just the JSON block.";
         }
 
         private static async Task NotifyEmptyEmails(List<int> userIds)
@@ -180,7 +198,9 @@ namespace OASystem.API.OAMethodLib.Quartz.Business
         {
             public string Recipient { get; set; } = string.Empty;
             public string EmailSubject { get; set; } = string.Empty;
-            public string HtmlBody { get; set; } = string.Empty;
+            public string TextBody { get; set; } = string.Empty;
         }
     }
 }
+
+

+ 2 - 1
OASystem/OASystem.Api/Program.cs

@@ -578,6 +578,7 @@ builder.Services.AddSingleton<TaskNewsFeedJob>();
 builder.Services.AddSingleton<PerformanceJob>();
 builder.Services.AddSingleton<GroupProcessNodeJob>();
 builder.Services.AddSingleton<WeeklyFridayJob>();
+builder.Services.AddSingleton<ProcessAndNotifySummaryJob>();
 //# new business
 builder.Services.AddControllersWithViews();
 builder.Services.AddSingleton<IAPNsService, APNsService>();
@@ -596,7 +597,7 @@ builder.Services.TryAddSingleton(typeof(CommonService));
 #endregion
 
 #region hotmail
-builder.Services.AddSingleton<HotmailService>();
+builder.Services.AddScoped<HotmailService>();
 #endregion
 
 #region Microsoft Graph 閭�绠辨湇鍔�