Просмотр исходного кода

Merge branch 'develop' of http://132.232.92.186:3000/yuanrf/OA2023 into develop

yuanrf дней назад: 6
Родитель
Сommit
baaafb9769

+ 71 - 32
OASystem/OASystem.Api/Controllers/AITestController.cs

@@ -1,15 +1,18 @@
 using Microsoft.AspNetCore.Mvc;
 using OASystem.API.OAMethodLib.DeepSeekAPI;
+using Flurl.Http.Configuration;
+using Microsoft.Extensions.Options;
 using OASystem.API.OAMethodLib.DoubaoAPI;
-using OASystem.API.OAMethodLib.HotmailEmail;
+using OASystem.API.OAMethodLib.Hotmail;
 using OASystem.API.OAMethodLib.HunYuanAPI;
 using OASystem.API.OAMethodLib.MicrosoftGraphMailbox;
 using OASystem.API.OAMethodLib.QiYeWeChatAPI;
+using OASystem.API.OAMethodLib.Quartz.Business;
 using OASystem.Domain.ViewModels.QiYeWeChat;
-using System.IO;
-using System.Threading.Tasks;
-using TencentCloud.Hunyuan.V20230901.Models;
 using OASystem.RedisRepository;
+using System.IdentityModel.Tokens.Jwt;
+using System.Text.Json;
+using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
 
 namespace OASystem.API.Controllers
 {
@@ -22,9 +25,13 @@ namespace OASystem.API.Controllers
         private readonly IHunyuanService _hunyuanService;
         private readonly IDoubaoService _doubaoService;
         private readonly ILogger<AITestController> _logger;
+        private readonly IConfiguration _config;
         private readonly IQiYeWeChatApiService _qiYeWeChatApiService;
-        private readonly IHotmailEmailService _hotmailEmailService;
+        private readonly System.Net.Http.IHttpClientFactory _httpClientFactory;
+        private readonly HotmailService _hotmailService;
         private readonly IMicrosoftGraphMailboxService _microsoftGraphMailboxService;
+        private readonly IOptionsMonitor<MicrosoftGraphMailboxOptions> _microsoftGraphMailboxOptions;
+
         private readonly IDeepSeekService _deepSeekService;
 
         public AITestController(
@@ -32,17 +39,24 @@ namespace OASystem.API.Controllers
             IDoubaoService doubaoService,
             ILogger<AITestController> logger,
             IQiYeWeChatApiService qiYeWeChatApiService,
-            IHotmailEmailService hotmailEmailService,
+            HotmailService hotmailService,
+            System.Net.Http.IHttpClientFactory httpClientFactory,
+            IConfiguration config,
             IMicrosoftGraphMailboxService microsoftGraphMailboxService,
-            IDeepSeekService deepSeekService)
+            IOptionsMonitor<MicrosoftGraphMailboxOptions> microsoftGraphMailboxOptions,
+            IDeepSeekService deepSeekService
+            )
         {
             _hunyuanService = hunyuanService;
             _doubaoService = doubaoService;
             _logger = logger;
             _qiYeWeChatApiService = qiYeWeChatApiService;
-            _hotmailEmailService = hotmailEmailService;
+            _hotmailService = hotmailService;
+            _httpClientFactory = httpClientFactory;
+            _config = config;
             _microsoftGraphMailboxService = microsoftGraphMailboxService;
             _deepSeekService = deepSeekService;
+            _microsoftGraphMailboxOptions = microsoftGraphMailboxOptions;
         }
 
         #region 企业微信发送邮件测试
@@ -403,34 +417,62 @@ namespace OASystem.API.Controllers
 
         #endregion
 
-        #region hotmail 测试
-        [HttpPost("send-notification")]
-        public async Task<IActionResult> SendNotification([FromBody] EmailRequest request)
+        /// <summary>
+        /// hotmail 发送邮件
+        /// </summary>
+        [HttpPost("hotmailSeed")]
+        public async Task<ActionResult<string>> HotmailSeed()
+        {
+            await _hotmailService.SendMailAsync(
+                //"Roy.Lei.Atom@hotmail.com",
+                "925554512@qq.com",
+                //"johnny.yang@pan-american-intl.com",
+                new HotmailService.MailDto()
+                {
+                    Subject = "系统提醒",
+                    Content = "<p>这是一封Homail 发送的测试邮件</p>",
+                    //To = "Roy.lei@pan-american-intl.com"
+                    To = "johnny.yang@pan-american-intl.com"
+                });
+
+            return StatusCode(200, new { Message = "操作成功。" });
+        }
+
+        /// <summary>
+        /// hotmail 发送邮件
+        /// </summary>
+        [HttpPost("HotmailMerged")]
+        public async Task<ActionResult<string>> HotmailMerged()
         {
-            if (string.IsNullOrEmpty(request.Email))
-                return BadRequest("邮箱地址不能为空");
 
-            // 调用服务
-            bool isSent = await _hotmailEmailService.SendEmailAsync(
-                request.Email,
-                "OASystem 业务提醒",
-                $"<p>您有一条新的待办事项:<b>{request.Content}</b></p>"
-            );
+            // 1. 获取当前北京时间 (CST)
+            var cstZone = CommonFun.GetCstZone();
+            var nowInCst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, cstZone);
 
-            if (isSent)
-                return Ok(new { code = 200, msg = "发送成功" });
+            // 2. 构造昨天的北京时间范围:00:00:00 到 23:59:59
+            var yesterdayStart = nowInCst.Date.AddDays(-1); // 昨天的 00:00:00
+            var yesterdayEnd = yesterdayStart.AddDays(1).AddTicks(-1); // 昨天的 23:59:59
 
-            return StatusCode(500, "邮件发送失败,请检查服务器配置或应用密码");
+
+            var res = await _hotmailService.GetMergedMessagesAsync(
+                new List<string>() { "925554512@qq.com" },
+                yesterdayStart,
+                yesterdayEnd
+                );
+
+            return StatusCode(200, res);
         }
 
-        // 定义请求实体
-        public class EmailRequest
+        /// <summary>
+        /// hotmail 定时发送邮件 汇总 测试
+        /// </summary>
+        [HttpPost("hotmailSummarySeedQW")]
+        public async Task<ActionResult<string>> HotmailSummary()
         {
-            public string Email { get; set; } = string.Empty;
-            public string Content { get; set; } = string.Empty;
-        }
+            ProcessAndNotifySummary.ProcessAndNotifySummaryAsync();
 
-        #endregion
+            return StatusCode(200, "发送成功");
+        }
 
         #region Microsoft Graph 邮箱测试(仅访问令牌)
 
@@ -549,8 +591,6 @@ namespace OASystem.API.Controllers
             }
         }
 
-
-
         public class EmailAuthRedisCache
         {
             public string? AccessToken { get; set; }
@@ -559,7 +599,6 @@ namespace OASystem.API.Controllers
             public string? ClientId { get; set; }
         }
 
-
         /// <summary>
         /// 从 Redis 读取 MSAL 缓存与 HomeAccountId,静默刷新 Graph access_token。
         /// </summary>
@@ -580,7 +619,7 @@ namespace OASystem.API.Controllers
             {
                 cacheEntry = JsonConvert.DeserializeObject<EmailAuthRedisCache>(json);
             }
-            catch (JsonException ex)
+            catch (System.Text.Json.JsonException ex)
             {
                 _logger.LogWarning(ex, "Redis 键 {Key} 内容不是合法 JSON(应用 StringGetRawAsync + JSON,勿用 StringGetAsync<T>,后者为 BinaryFormatter)", key);
                 return BadRequest(new { message = "Redis 值为 JSON 文本时须用 StringGetRawAsync 再反序列化;StringGetAsync<T> 仅适用于 BinaryFormatter 写入的数据", detail = ex.Message });

+ 163 - 2
OASystem/OASystem.Api/Controllers/AuthController.cs

@@ -1,8 +1,10 @@
 
+using Flurl.Http.Configuration;
 using Microsoft.AspNetCore.SignalR;
 using Microsoft.EntityFrameworkCore.Metadata.Internal;
 using NPOI.SS.Formula.Functions;
 using OASystem.API.OAMethodLib;
+using OASystem.API.OAMethodLib.Hotmail;
 using OASystem.API.OAMethodLib.Hub.HubClients;
 using OASystem.API.OAMethodLib.Hub.Hubs;
 using OASystem.API.OAMethodLib.QiYeWeChatAPI;
@@ -13,6 +15,8 @@ using OASystem.Domain.Entities.Customer;
 using OASystem.Domain.Entities.Groups;
 using OASystem.Infrastructure.Repositories.Login;
 using System.IdentityModel.Tokens.Jwt;
+using System.Text.Json;
+using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
 using static OASystem.API.OAMethodLib.JWTHelper;
 
 namespace OASystem.API.Controllers
@@ -32,6 +36,9 @@ namespace OASystem.API.Controllers
         private readonly IQiYeWeChatApiService _qiYeWeChatApiService;
         private readonly IHubContext<ChatHub, IChatClient> _hubContext;
         private readonly DeviceTokenRepository _deviceTokenRep;
+        private readonly HotmailService _hotmailService;
+
+        private readonly System.Net.Http.IHttpClientFactory _httpClientFactory;
 
         /// <summary>
         /// 
@@ -45,6 +52,8 @@ namespace OASystem.API.Controllers
         /// <param name="messageRep"></param>
         /// <param name="deviceRep"></param>
         /// <param name="hubContext"></param>
+        /// <param name="hotmailService"></param>
+        /// <param name="httpClientFactory"></param>
         public AuthController(
             IConfiguration config, 
             LoginRepository loginRep, 
@@ -54,8 +63,9 @@ namespace OASystem.API.Controllers
             IQiYeWeChatApiService qiYeWeChatApiService, 
             MessageRepository messageRep,
             DeviceTokenRepository deviceRep,
-            IHubContext<ChatHub, 
-                IChatClient> hubContext)
+            IHubContext<ChatHub,IChatClient> hubContext, 
+            HotmailService hotmailService,
+            System.Net.Http.IHttpClientFactory httpClientFactory)
         {
             _config = config;
             _loginRep = loginRep;
@@ -66,6 +76,8 @@ namespace OASystem.API.Controllers
             _messageRep = messageRep;
             _deviceTokenRep = deviceRep;
             _hubContext = hubContext;
+            _hotmailService = hotmailService;
+            _httpClientFactory = httpClientFactory;
         }
 
         /// <summary>
@@ -448,6 +460,155 @@ namespace OASystem.API.Controllers
             return Ok(JsonView(false, view.Msg));
         }
 
+
+        #region microsoft 鉴权验证
+
+        /// <summary>
+        /// microsoft - hotmail 鉴权验证
+        /// </summary>
+        /// <returns></returns>
+        [HttpGet("microsoft/auth/verify/{currUserId}")]
+        [ProducesResponseType(typeof(LoginView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> MicrosoftHotmailPrepareAuth(int currUserId)
+        {
+            var (code, message) = await _hotmailService.PrepareAuth(currUserId);
+
+            return code switch
+            {
+                // 无需授权
+                0 => Ok(JsonView(true, "已通过验证", new { isAuth = false })),
+
+                // 需要跳转授权 (1)
+                1 => Ok(JsonView(true, "请点击链接进行 Auth 验证!", new { isAuth = true, url = message })),
+                //1 => Redirect(message),  
+
+                // 配置错误或异常 (-1)
+                _ => Ok(JsonView(false, message))
+            };
+
+        }
+
+        /// <summary>
+        /// microsoft auth 回调
+        /// </summary>
+        /// <param name="code"></param>
+        /// <param name="state"></param>
+        /// <returns></returns>
+        [HttpGet("microsoft/auth/callback")]
+        public async Task<IActionResult> HandleCallback([FromQuery] string code, [FromQuery] string state)
+        {
+            if (string.IsNullOrEmpty(code)) return BadRequest("授权码无效");
+
+            // 1. 状态与配置校验
+            if (!int.TryParse(state, out int userId)) return BadRequest("非法的 state 标识");
+
+            var config = await _hotmailService.GetUserMailConfig(userId);
+            if (config == null) return BadRequest("对应配置信息不存在");
+
+            try
+            {
+                // 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", "openid offline_access Mail.ReadWrite Mail.Send User.Read" }
+                    
+                };
+
+                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($"令牌交换失败: {responseContent}");
+
+                var root = JsonDocument.Parse(responseContent).RootElement;
+
+                // 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();
+
+                if (string.IsNullOrEmpty(refreshToken))
+                    return BadRequest("未能获取长效刷新令牌,请检查 offline_access 权限。");
+
+                // 4. 获取用户信息
+                string userEmail = await GetEmailFromGraphApiAsync(accessToken);
+
+                // 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, System.Text.Json.JsonSerializer.Serialize(userToken), TimeSpan.FromDays(90));
+
+                // 6. 返回结果 
+                return Ok(new
+                {
+                    status = "Success",
+                    account = userEmail,
+                    expiresInSeconds = expiresIn,
+                    source = userToken.Source
+                });
+            }
+            catch (Exception ex)
+            {
+                // _logger.LogError(ex, "Callback processing failed");
+                return StatusCode(500, "回调失败,请检查控制台日志");
+            }
+        }
+
+        private async Task<string> GetEmailFromGraphApiAsync(string accessToken)
+        {
+            // 1. 使用 HttpClientFactory 获取预设或独立的 Client
+            var httpClient = _httpClientFactory.CreateClient("MicrosoftGraph");
+
+            // 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)
+            {
+                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 (登录名)
+            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
+
+
         /// <summary>
         /// 测试auth
         /// </summary>

+ 72 - 57
OASystem/OASystem.Api/Controllers/ResourceController.cs

@@ -1,6 +1,7 @@
 using Aspose.Cells;
 using Aspose.Words;
 using Aspose.Words.Tables;
+using Azure;
 using EyeSoft.Extensions;
 using Microsoft.AspNetCore.Http.Features;
 using Newtonsoft.Json.Serialization;
@@ -8,6 +9,7 @@ using NodaTime;
 using NPOI.SS.Formula.Functions;
 using NPOI.SS.UserModel;
 using OASystem.API.OAMethodLib;
+using OASystem.API.OAMethodLib.Hotmail;
 using OASystem.API.OAMethodLib.HunYuanAPI;
 using OASystem.API.OAMethodLib.QiYeWeChatAPI;
 using OASystem.API.OAMethodLib.QiYeWeChatAPI.AppNotice;
@@ -25,6 +27,7 @@ using System.Diagnostics;
 using TencentCloud.Common;
 using static NodaTime.TimeZones.TzdbZone1970Location;
 using static OASystem.API.OAMethodLib.GeneralMethod;
+using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
 using static OASystem.API.OAMethodLib.JWTHelper;
 using static OpenAI.GPT3.ObjectModels.Models;
 
@@ -65,6 +68,7 @@ namespace OASystem.API.Controllers
         private readonly GamesBudgetMasterRepository _gamesBudgetMasterRep;
         private readonly OverseaVehicleRepository _overseaVehicleRep;
         private readonly MaterialCostRepository _materialCostRep;
+        private readonly HotmailService _hotmailService;
         /// <summary>
         /// 签证费用归属省份静态数据
         /// </summary>
@@ -82,6 +86,7 @@ namespace OASystem.API.Controllers
             ILogger<ResourceController> logger,
             IHunyuanService hunyuanService,
             IQiYeWeChatApiService qiYeWeChatApiService,
+            HotmailService hotmailService,
             SqlSugarClient sqlSugar,
             CarDataRepository carDataRep,
             LocalGuideDataRepository localGuideDataRep,
@@ -111,6 +116,7 @@ namespace OASystem.API.Controllers
             _logger = logger;
             _hunyuanService = hunyuanService;
             _qiYeWeChatApiService = qiYeWeChatApiService;
+            _hotmailService = hotmailService;
             _sqlSugar = sqlSugar;
             _carDataRep = carDataRep;
             _localGuideDataRep = localGuideDataRep;
@@ -2359,6 +2365,8 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                 info.EntryInfo.TargetCountry = _delegationInfoRep.GroupSplitCountry(groupInfo?.TeamName ?? "");
             }
 
+            info.AiCrawledDetails = info.AiCrawledDetails.OrderByDescending(x => x.IsChecked).OrderByDescending(x => x.OperatedAt).ToList();
+
             var view = new
             {
                 info.Id,
@@ -2767,7 +2775,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)
         {
@@ -3206,7 +3214,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())
@@ -4274,11 +4282,17 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
 
             var seedInvInfos = dataList.Where(x => dto.Guids.Contains(x.Guid)).ToList();
 
-            if (seedInvInfos == null) Ok(JsonView(false, "发送邮件的邀请方信息为空"));
+            if (seedInvInfos == null) return Ok(JsonView(false, "发送邮件的邀请方信息为空"));
 
             var userInfo = await _sqlSugar.Queryable<Sys_Users>().Where(x => x.IsDel == 0 && x.Id == dto.CurrUserId).Select(x => new { x.Email, x.CnName }).FirstAsync();
 
-            if (string.IsNullOrEmpty(userInfo.Email)) Ok(JsonView(false, "当前账号未配置邮箱,请先配置邮箱!"));
+            if (string.IsNullOrEmpty(userInfo.Email)) return Ok(JsonView(false, "当前账号未配置邮箱,请先配置邮箱!"));
+
+            // hotmail 配置信息验证
+            var hotmailConfig = await _hotmailService.GetUserMailConfig(dto.CurrUserId);
+
+            (bool verify, string msg) = _hotmailService.ConfigVerify(hotmailConfig);
+            if (!verify) return Ok(JsonView(false, msg));
 
             var msgSb = new StringBuilder();
             #region 发送邮件
@@ -4287,40 +4301,44 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             {
                 int seedStatus = 5;
 
-                if (!string.IsNullOrEmpty(item.EmailInfo.EmailTitle) && !string.IsNullOrEmpty(item.EmailInfo.EmailContent))
+
+                if (string.IsNullOrEmpty(item.EmailInfo?.EmailTitle) || string.IsNullOrEmpty(item.EmailInfo?.EmailContent))
                 {
-                    try
+                    msgSb.Append($"跳过:{item.NameCn} (邮件标题或内容缺失)");
+                    continue;
+                }
+
+                try
+                {
+                    var req = new MailDto()
                     {
-                        var req = new EmailRequestDto()
-                        {
-                            ToEmails = new List<string> { item.Email },
-                            //CcEmails = new List<string> { userInfo.Email },
-                            //BccEmails = new List<string> { userInfo.Email },
-                            Subject = item.EmailInfo.EmailTitle,
-                            Body = item.EmailInfo.EmailContent,
-                            Files = Array.Empty<IFormFile>()
-                        };
-                        var response = await _qiYeWeChatApiService.EmailSendAsync(req);
-                        if (response.errcode == 0)
-                        {
-                            seedStatus = 4;
-                            item.EmailInfo.Status = seedStatus;
-                            item.EmailInfo.Operator = userInfo?.CnName ?? "";
-                            item.EmailInfo.OperatedAt = DateTime.Now;
+                        Subject = item.EmailInfo.EmailTitle,
+                        Content = item.EmailInfo.EmailContent,
+                        To = item.Email
+                    };
+
+                    var res = await _hotmailService.SendMailAsync(hotmailConfig.UserName, req);
 
-                            msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送成功!");
-                        }
-                        else
-                        {
-                            msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送失败(msg:{response.errmsg})");
-                        }
 
+                    if (res.IsSuccess)
+                    {
+                        seedStatus = 4;
+                        item.EmailInfo.Status = seedStatus;
+                        item.EmailInfo.Operator = userInfo?.CnName ?? "";
+                        item.EmailInfo.OperatedAt = DateTime.Now;
+
+                        msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送成功!");
                     }
-                    catch (Exception ex)
+                    else
                     {
-                        _logger.LogError(ex, "商邀AI调用企业微信邮件API失败。");
-                        msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送失败(msg:商邀AI调用企业微信邮件API失败。)");
+                        msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送失败(msg:{res.Message})");
                     }
+
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "商邀AI调用企业微信邮件API失败。");
+                    msgSb.AppendLine($"{item.NameCn}({item.Email}):邮件发送失败(msg:商邀AI调用企业微信邮件API失败。)");
                 }
             }
 
@@ -4353,7 +4371,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
 
             try
             {
-                await HttpContext.SendSseStepAsync(5, "正在准备发送队列...");
+                await HttpContext.SendSseStepAsync(5, "正在准备发送邮件队列...");
 
                 #region 1. 参数与权限前置校验
                 if (dto.Id < 1 || dto.CurrUserId < 1 || dto.Guids == null || !dto.Guids.Any())
@@ -4362,16 +4380,23 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     return;
                 }
 
-                // 并行获取团组信息和用户信息
-                var invAiTask = _sqlSugar.Queryable<Res_InvitationAI>().InSingleAsync(dto.Id);
-                var userTask = _sqlSugar.Queryable<Sys_Users>()
-                    .Where(x => x.IsDel == 0 && x.Id == dto.CurrUserId)
-                    .Select(x => new { x.Email, x.CnName }).FirstAsync();
+                // hotmail 配置信息验证
+                var hotmailConfig = await _hotmailService.GetUserMailConfig(dto.CurrUserId);
+                
+                (bool verify,string msg) = _hotmailService.ConfigVerify(hotmailConfig);
+                if (!verify)
+                {
+                    await HttpContext.SendSseStepAsync(-1, msg);
+                    return;
+                }
 
-                await Task.WhenAll(invAiTask, userTask);
+                // 获取商邀信息和用户信息
+                var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().FirstAsync(x => x.IsDel == 0 && x.Id == dto.Id);
+                var userInfo = await _sqlSugar.Queryable<Sys_Users>()
+                    .Where(x => x.IsDel == 0 && x.Id == dto.CurrUserId)
+                    .Select(x => new { x.Email, x.CnName })
+                    .FirstAsync();
 
-                var invAiInfo = await invAiTask;
-                var userInfo = await userTask;
 
                 if (invAiInfo?.AiCrawledDetails == null)
                 {
@@ -4379,12 +4404,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     return;
                 }
 
-                if (string.IsNullOrEmpty(userInfo?.Email))
-                {
-                    await HttpContext.SendSseStepAsync(-1, "当前账号未配置邮箱,无法执行发送。");
-                    return;
-                }
-
                 // 提取待发送的目标集合
                 var guidSet = new HashSet<string>(dto.Guids);
                 var seedInvInfos = invAiInfo.AiCrawledDetails.Where(x => guidSet.Contains(x.Guid)).ToList();
@@ -4394,6 +4413,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     await HttpContext.SendSseStepAsync(-1, "所选单位信息在原始库中不存在。");
                     return;
                 }
+
                 #endregion
 
                 await HttpContext.SendSseStepAsync(15, $"准备就绪,共计 {seedInvInfos.Count} 封邮件待发送...");
@@ -4419,18 +4439,15 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
 
                     try
                     {
-                        var req = new EmailRequestDto()
-                        {
-                            ToEmails = new List<string> { item.Email },
+                        var req = new MailDto() {
                             Subject = item.EmailInfo.EmailTitle,
-                            Body = item.EmailInfo.EmailContent,
-                            Files = Array.Empty<IFormFile>()
+                            Content = item.EmailInfo.EmailContent,
+                            To = item.Email
                         };
 
-                        // 调用企微 API
-                        var response = await _qiYeWeChatApiService.EmailSendAsync(req);
+                        var res = await _hotmailService.SendMailAsync(hotmailConfig.UserName, req);
 
-                        if (response.errcode == 0)
+                        if (res.IsSuccess)
                         {
                             successCount++;
                             // 更新本地状态
@@ -4443,7 +4460,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                         else
                         {
                             failCount++;
-                            await HttpContext.SendSseStepAsync(progress, $"失败:{item.NameCn} 发送失败 ({response.errmsg})");
+                            await HttpContext.SendSseStepAsync(progress, $"失败:{item.NameCn} 发送失败 ({res.Message})");
                         }
                     }
                     catch (Exception ex)
@@ -4453,8 +4470,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                         await HttpContext.SendSseStepAsync(progress, $"异常:{item.NameCn} 连接超时");
                     }
 
-                    // 频率控制:防止企微 API 触发 QPS 限制 (炼金建议:根据实际情况调整)
-                    await Task.Delay(300);
                 }
                 #endregion
 

+ 481 - 0
OASystem/OASystem.Api/OAMethodLib/Hotmail/HotmailService.cs

@@ -0,0 +1,481 @@
+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;
+        }
+
+        /// <summary>
+        /// 统一获取 Redis Key
+        /// </summary>
+        public static string GetRedisKey(string email) => $"{RedisKeyPrefix}{email.Trim().ToLower()}";
+
+        /// <summary>
+        /// hotmail 信息验证
+        /// </summary>
+        /// <param name="config"></param>
+        /// <returns></returns>
+        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, "");
+        }
+
+        /// <summary>
+        /// Microsoft 鉴权预处理
+        /// </summary>
+        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<string>(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<string, string?>
+            {
+                { "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<List<MailDto>> GetMergedMessagesAsync(List<string> emails, DateTime cstStart, DateTime cstEnd)
+        {
+            // 线程安全的合并容器
+            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);
+                    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();
+        }
+
+        /// <summary>
+        /// 指定账户发送邮件
+        /// </summary>
+        public async Task<MailSendResult> 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<Recipient>
+                {
+                    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}"
+                };
+            }
+        }
+
+        /// <summary>
+        /// 获取邮箱配置信息 - single
+        /// </summary>
+        /// <returns></returns>
+        public async Task<HotmailConfig?> GetUserMailConfig(int userId)
+        {
+            var allConfigs = await GetUserMailConfigListAsync();
+
+            if (allConfigs == null || !allConfigs.Any()) return null;
+
+            var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId);
+
+            return userConfig;
+        }
+
+        /// <summary>
+        /// 获取邮箱配置信息 - ALL
+        /// </summary>
+        /// <returns></returns>
+        public async Task<List<HotmailConfig>?> GetUserMailConfigListAsync()
+        {
+            var remark = await _sqlSugar.Queryable<Sys_SetData>()
+                .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<List<HotmailConfig>>(remark);
+                return allConfigs;
+            }
+            catch (Exception)
+            {
+                return null;
+            }
+        }
+
+        /// <summary>
+        /// 线程锁
+        /// </summary>
+        private static readonly ConcurrentDictionary<string, SemaphoreSlim> _userLocks = new ConcurrentDictionary<string, SemaphoreSlim>();
+
+        /// <summary>
+        /// 获取 Graph 客户端,处理 Token 自动刷新 (线程安全版)
+        /// </summary>
+        private async Task<GraphServiceClient> 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<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);
+                }
+
+                // 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<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", 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;
+        }
+
+        /// <summary>
+        /// 静态 Token 提供者辅助类
+        /// </summary>
+        public class StaticTokenProvider : IAccessTokenProvider
+        {
+            private readonly string _token;
+            public StaticTokenProvider(string token) => _token = token;
+            public Task<string> GetAuthorizationTokenAsync(Uri uri, Dictionary<string, object>? 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";
+        }
+
+        /// <summary>
+        /// Hotmail 邮件服务 OAuth2 配置信息实体
+        /// </summary>
+        public class HotmailConfig
+        {
+            /// <summary>
+            /// 用户唯一标识
+            /// </summary>
+            [JsonPropertyName("userId")]
+            public int UserId { get; set; }
+
+            /// <summary>
+            /// 账号用户名
+            /// </summary>
+            [JsonPropertyName("userName")]
+            public string UserName { get; set; }
+
+            /// <summary>
+            /// Azure AD 租户标识符 (Guid)
+            /// </summary>
+            [JsonPropertyName("tenantId")]
+            public string TenantId { get; set; }
+
+            /// <summary>
+            /// 注册应用程序的客户端标识
+            /// </summary>
+            [JsonPropertyName("clientId")]
+            public string ClientId { get; set; }
+
+            /// <summary>
+            /// 客户端密钥(敏感数据建议加密存储)
+            /// </summary>
+            [JsonPropertyName("clientSecret")]
+            public string ClientSecret { get; set; }
+
+            /// <summary>
+            /// 租户类型(如 common, organizations 或具体域名)
+            /// </summary>
+            [JsonPropertyName("tenant")]
+            public string Tenant { get; set; } = "common";
+
+            /// <summary>
+            /// OAuth2 回调重定向地址
+            /// </summary>
+            [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; }
+        }
+
+        /// <summary>
+        /// 邮件请求对象
+        /// </summary>
+        public class MailDto
+        {
+            /// <summary>
+            /// 邮件唯一标识符 (UID/Message-ID)
+            /// </summary>
+            [JsonPropertyName("messageId")]
+            public string? MessageId { get; set; }
+
+            /// <summary>
+            /// 邮件主题
+            /// </summary>
+            [JsonPropertyName("subject")]
+            public string? Subject { get; set; }
+
+            /// <summary>
+            /// 发件人地址 (e.g. "sender@example.com")
+            /// </summary>
+            [JsonPropertyName("from")]
+            public string? From { get; set; }
+
+            /// <summary>
+            /// 收件人地址
+            /// </summary>
+            [JsonPropertyName("to")]
+            public string? To { get; set; }
+
+            /// <summary>
+            /// 邮件正文内容 (HTML 或纯文本)
+            /// </summary>
+            [JsonPropertyName("content")]
+            public string? Content { get; set; }
+
+            /// <summary>
+            /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性
+            /// </summary>
+            [JsonPropertyName("receivedTime")]
+            public DateTimeOffset? ReceivedTime { get; set; }
+
+            /// <summary>
+            /// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData")
+            /// </summary>
+            [JsonPropertyName("source")]
+            public string? Source { get; set; } = "Hotmail";
+        }
+        #endregion
+    }
+}

+ 0 - 77
OASystem/OASystem.Api/OAMethodLib/HotmailEmail/HotmailEmailService.cs

@@ -1,77 +0,0 @@
-using MailKit.Net.Smtp;
-using MailKit.Security;
-using MimeKit;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-
-namespace OASystem.API.OAMethodLib.HotmailEmail
-{
-    public class HotmailEmailService : IHotmailEmailService
-    {
-        private readonly IConfiguration _config;
-        private readonly ILogger<HotmailEmailService> _logger;
-
-        public HotmailEmailService(IConfiguration config, ILogger<HotmailEmailService> logger)
-        {
-            _config = config;
-            _logger = logger;
-        }
-
-        public async Task<bool> SendEmailAsync(string toEmail, string subject, string htmlContent)
-        {
-            // 1. 配置前置校验
-            string smtpServer = _config["HotEmailConfig:SmtpServer"] ?? string.Empty;
-            int smtpPort = _config.GetValue<int>("HotEmailConfig:SmtpPort", 587);
-            string appPassword = _config["HotEmailConfig:AppPassword"];
-            string senderEmail = _config["HotEmailConfig:SenderEmail"];
-            string senderName = _config["HotEmailConfig:SenderName"];
-
-            if (string.IsNullOrEmpty(smtpServer) || string.IsNullOrEmpty(appPassword))
-            {
-                _logger.LogError("[Config Alchemy] 无法从 JSON 获取必要的邮件配置节点。");
-                return false;
-            }
-
-            // 2. 构建邮件内容
-            var message = new MimeMessage();
-            message.From.Add(new MailboxAddress(senderName, senderEmail));
-
-            if (!MailboxAddress.TryParse(toEmail, out var recipient))
-            {
-                _logger.LogWarning("[Email] 收件人地址非法: {ToEmail}", toEmail);
-                return false;
-            }
-            message.To.Add(recipient);
-            message.Subject = subject;
-
-            var bodyBuilder = new BodyBuilder { HtmlBody = htmlContent };
-            message.Body = bodyBuilder.ToMessageBody();
-
-            // 3. 客户端连接与发送
-            using var client = new SmtpClient();
-            try
-            {
-                // Hotmail/Outlook 通常使用 587 + StartTls 或 465 + SslOnConnect
-                var secureOption = smtpPort == 465
-                    ? SecureSocketOptions.SslOnConnect
-                    : SecureSocketOptions.StartTls;
-
-                // 设置超时 (建议 30-60 秒)
-                client.Timeout = 30000;
-
-                await client.ConnectAsync(smtpServer, smtpPort, secureOption);
-                await client.AuthenticateAsync(senderEmail, appPassword);
-                await client.SendAsync(message);
-                await client.DisconnectAsync(true);
-
-                _logger.LogInformation("[Email] 成功发送至: {ToEmail}", toEmail);
-                return true;
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "[Email] 发送失败,目标: {ToEmail}", toEmail);
-                return false;
-            }
-        }
-    }
-}

+ 0 - 7
OASystem/OASystem.Api/OAMethodLib/HotmailEmail/IHotmailEmailService.cs

@@ -1,7 +0,0 @@
-namespace OASystem.API.OAMethodLib.HotmailEmail
-{
-    public interface IHotmailEmailService
-    {
-        Task<bool> SendEmailAsync(string toEmail, string subject, string htmlContent);
-    }
-}

+ 206 - 0
OASystem/OASystem.Api/OAMethodLib/Quartz/Business/ProcessAndNotifySummary.cs

@@ -0,0 +1,206 @@
+using OASystem.API.OAMethodLib.DeepSeekAPI;
+using OASystem.API.OAMethodLib.Hotmail;
+using OASystem.API.OAMethodLib.QiYeWeChatAPI;
+using OASystem.Domain.ViewModels.QiYeWeChat;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Web;
+using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
+
+namespace OASystem.API.OAMethodLib.Quartz.Business
+{
+    /// <summary>
+    /// 获取hotmail邮件,自动总结邮件内容发送至企业微信邮件
+    /// </summary>
+    public static class ProcessAndNotifySummary
+    {
+        private static readonly SqlSugarClient _sqlSugar = AutofacIocManager.Instance.GetService<SqlSugarClient>();
+        private static readonly IQiYeWeChatApiService _qiYeWeChatApiService = AutofacIocManager.Instance.GetService<IQiYeWeChatApiService>();
+        private static readonly HotmailService _hotmailService = AutofacIocManager.Instance.GetService<HotmailService>();
+        private static readonly IDeepSeekService _deepSeekService = AutofacIocManager.Instance.GetService<IDeepSeekService>();
+
+        /// <summary>
+        /// hotmail 邮件 汇总 发送企微邮件
+        /// 时间范围 昨天
+        /// </summary>
+        public static async void ProcessAndNotifySummaryAsync()
+        {
+            var hotmailConfigs = await _hotmailService.GetUserMailConfigListAsync();
+            if (hotmailConfigs == null || !hotmailConfigs.Any()) return;
+
+            var hotmails = hotmailConfigs.Select(x => x.UserName).ToList();
+            var userIds = hotmailConfigs.Select(x => x.UserId).ToList();
+
+            var cstZone = CommonFun.GetCstZone();
+            var nowInCst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, cstZone);
+            var yesterdayStart = nowInCst.Date.AddDays(-1);
+            var yesterdayEnd = yesterdayStart.AddDays(1).AddTicks(-1);
+
+            // 获取邮件信息
+            var emailInfos = await _hotmailService.GetMergedMessagesAsync(hotmails, yesterdayStart, yesterdayEnd);
+
+            // 处理无邮件情况
+            if (emailInfos == null || !emailInfos.Any())
+            {
+                await NotifyEmptyEmails(userIds);
+                return;
+            }
+
+            //// 预处理:限制每封邮件正文长度,防止 Token 溢出
+            //foreach (var mail in emailInfos)
+            //{
+            //    mail.Content = CleanHtmlToPlainText(mail.Content);
+            //}
+
+            // 调用 AI
+            var question = BuildMailSummaryPrompt(emailInfos);
+            var res = await _deepSeekService.ChatAsync(question);
+
+            if (res.Success)
+            {
+                // 清洗 AI 可能带出的 Markdown 格式符
+                string cleanJson = res.Answer.Trim();
+                if (cleanJson.StartsWith("```json")) cleanJson = cleanJson.Replace("```json", "").Replace("```", "").Trim();
+
+                try
+                {
+                    var aiSummaryResults = JsonConvert.DeserializeObject<List<AiSummaryResult>>(cleanJson);
+                    var users = await _sqlSugar.Queryable<Sys_Users>().Where(x => x.IsDel == 0 && userIds.Contains(x.Id)).Select(x => new { x.Id, x.Email }).ToListAsync();
+
+                    foreach (var hotmailConfig in hotmailConfigs)
+                    {
+                        var qwEmail = users.FirstOrDefault(x => x.Id == hotmailConfig.UserId)?.Email;
+                        if (string.IsNullOrEmpty(qwEmail)) continue;
+
+                        if (hotmailConfig.UserName.Equals("925554512@qq.com") || hotmailConfig.UserName.Equals("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 = "未能获取到hotmail邮件。";
+
+                        if (summary != null)
+                        {
+                            finalSubject = $"[AI摘要] {summary.EmailSubject}";
+                            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,
+                                defualtEmail1,
+                                defualtEmail2
+                            },
+                            Subject = finalSubject,
+                            Body = finalBody,
+                        });
+                    }
+                }
+                catch (Exception ex)
+                {
+                    // 记录解析 JSON 失败日志
+                }
+            }
+        }
+
+        /// <summary>
+        /// 纯正则实现:剔除 HTML 标签、样式和脚本,保留核心文本
+        /// </summary>
+        private static string CleanHtmlToPlainText(string? html)
+        {
+            if (string.IsNullOrEmpty(html)) return string.Empty;
+
+            // 1. 剔除脚本 (Script) 和 样式 (Style) 及其内部内容
+            html = Regex.Replace(html, @"<(script|style)[^>]*?>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
+
+            // 2. 剔除所有 HTML 标签
+            html = Regex.Replace(html, @"<[^>]*>", " ");
+
+            // 3. 将 HTML 实体字符转换回普通字符 (例如 &nbsp; -> 空格, &lt; -> <)
+            html = HttpUtility.HtmlDecode(html);
+
+            // 4. 清理多余的空白字符和重复换行
+            html = Regex.Replace(html, @"\s+", " "); // 将多个空格/换行合并为一个空格
+            html = Regex.Replace(html, @"(\n\s*){2,}", "\n"); // 压缩重复换行
+
+            return html.Trim();
+        }
+
+        public static string BuildMailSummaryPrompt(List<MailDto> mailList)
+        {
+            var rawDataJson = System.Text.Json.JsonSerializer.Serialize(mailList, new JsonSerializerOptions { WriteIndented = false });
+
+            return $@"
+# Role: .NET 邮件情报分析引擎 (JSON-ONLY Mode)
+
+## Task
+解析以下 `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)
+        {
+            var userEmails = await _sqlSugar.Queryable<Sys_Users>().Where(x => x.IsDel == 0 && userIds.Contains(x.Id)).Select(x => x.Email).ToListAsync();
+            if (userEmails.Any())
+            {
+                await _qiYeWeChatApiService.EmailSendAsync(new EmailRequestDto
+                {
+                    ToEmails = userEmails,
+                    Subject = $"{DateTime.Now:yyyy-MM-dd} - 邮件汇总",
+                    Body = "昨日暂未收到有效邮件。"
+                });
+            }
+        }
+
+        public class AiSummaryResult
+        {
+            public string Recipient { get; set; } = string.Empty;
+            public string EmailSubject { get; set; } = string.Empty;
+            public string TextBody { get; set; } = string.Empty;
+        }
+    }
+}
+
+

+ 26 - 0
OASystem/OASystem.Api/OAMethodLib/Quartz/Jobs/ProcessAndNotifySummaryJob.cs

@@ -0,0 +1,26 @@
+using OASystem.API.OAMethodLib.Quartz.Business;
+using Quartz;
+using QuzrtzJob.Factory;
+
+namespace OASystem.API.OAMethodLib.Quartz.Jobs
+{
+    /// <summary>
+    /// hotmail邮件汇总 发送
+    /// </summary>
+    public class ProcessAndNotifySummaryJob : IJob
+    {
+        private readonly ILogger<ProcessAndNotifySummaryJob> _logger;
+        public ProcessAndNotifySummaryJob(ILogger<ProcessAndNotifySummaryJob> logger)
+        {
+            _logger = logger;
+        }
+
+        public Task Execute(IJobExecutionContext context)
+        {
+            _logger.LogInformation("hotmail汇总发送邮件 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
+            //在此处编写任务业务代码
+            ProcessAndNotifySummary.ProcessAndNotifySummaryAsync();
+            return Task.CompletedTask;
+        }
+    }
+}

+ 8 - 0
OASystem/OASystem.Api/OAMethodLib/Quartz/QuartzFactory.cs

@@ -50,17 +50,23 @@ namespace QuzrtzJob.Factory
                 .WithCronSchedule("0 0 5 1 * ?") // 每月1号5点执行
                 .Build();
 
+            var hotmailSummaryTrigger = TriggerBuilder.Create()
+                .WithCronSchedule("0 0 8 * * ?") // 每月1号5点执行
+                .Build();
+
             //5、创建任务
             var jobDetail = JobBuilder.Create<ALiYunPostMessageJob>().WithIdentity("job1", "group").Build();
             var taskJobDetail = JobBuilder.Create<TaskJob>().WithIdentity("job2", "group").Build();
             var taskMsgJobDetail = JobBuilder.Create<TaskNewsFeedJob>().WithIdentity("job3", "group").Build();
             var performanceJobDetail = JobBuilder.Create<PerformanceJob>().WithIdentity("job5", "group").Build();
+            var hotmailSummaryJobDetail = JobBuilder.Create<ProcessAndNotifySummaryJob>().WithIdentity("job8", "group").Build();
 
             //6、将触发器和任务器绑定到调度器中
             await _scheduler.ScheduleJob(jobDetail, trigger);
             await _scheduler.ScheduleJob(taskJobDetail, taskTrigger);
             await _scheduler.ScheduleJob(taskMsgJobDetail, taskMsgTrigger);
             await _scheduler.ScheduleJob(performanceJobDetail, performanceTrigger);
+            await _scheduler.ScheduleJob(hotmailSummaryJobDetail, hotmailSummaryTrigger);
 
             // 币种信息 每天 凌晨零点更新
             await CreateAndScheduleJob<GroupTeamCurrencyJob>("job4", "group", CreateTrigger("0 0 0 * * ?"));
@@ -71,6 +77,8 @@ namespace QuzrtzJob.Factory
             // 每周五下午4点执行
             await CreateAndScheduleJob<WeeklyFridayJob>("job7", "group", CreateTrigger("0 0 16 ? * FRI"));
 
+            // hotmail 邮件汇总 每天早上八点
+
             return await Task.FromResult("将触发器和任务器绑定到调度器中完成");
         }
 

+ 1 - 1
OASystem/OASystem.Api/OASystem.API.csproj

@@ -58,7 +58,7 @@
     <PackageReference Include="Markdig" Version="0.33.0" />
     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.11" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.0" />
-    <PackageReference Include="Microsoft.Identity.Client" Version="4.66.2" />
+    <PackageReference Include="Microsoft.Graph" Version="5.103.0" />
     <PackageReference Include="NodaTime" Version="3.2.0" />
     <PackageReference Include="NPOI" Version="2.7.1" />
     <PackageReference Include="PinYinConverterCore" Version="1.0.2" />

+ 3 - 3
OASystem/OASystem.Api/Program.cs

@@ -11,7 +11,7 @@ using OASystem.API.OAMethodLib.AMapApi;
 using OASystem.API.OAMethodLib.APNs;
 using OASystem.API.OAMethodLib.DeepSeekAPI;
 using OASystem.API.OAMethodLib.GenericSearch;
-using OASystem.API.OAMethodLib.HotmailEmail;
+using OASystem.API.OAMethodLib.Hotmail;
 using OASystem.API.OAMethodLib.Hub.Hubs;
 using OASystem.API.OAMethodLib.HunYuanAPI;
 using OASystem.API.OAMethodLib.JuHeAPI;
@@ -30,7 +30,6 @@ using TencentCloud.Common.Profile;
 using TencentCloud.Hunyuan.V20230901;
 using static OASystem.API.Middlewares.RateLimitMiddleware;
 using OASystem.API.OAMethodLib.MicrosoftGraphMailbox;
-using OASystem.API.OAMethodLib.HotmailEmail;
 
 Console.Title = $"FMGJ OASystem Server";
 var builder = WebApplication.CreateBuilder(args);
@@ -579,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>();
@@ -597,7 +597,7 @@ builder.Services.TryAddSingleton(typeof(CommonService));
 #endregion
 
 #region hotmail
-builder.Services.AddTransient<IHotmailEmailService, HotmailEmailService>();
+builder.Services.AddScoped<HotmailService>();
 #endregion
 
 #region Microsoft Graph 閭�绠辨湇鍔�

+ 11 - 13
OASystem/OASystem.Api/appsettings.json

@@ -596,17 +596,15 @@
     "Region": "ap-chengdu",
     "Version": "2023-09-01"
   },
-  // 邮件发送配置
-  "HotEmailConfig": {
-    "SmtpServer": "smtp-mail.outlook.com",
-    "SmtpPort": 587,
-    "SenderEmail": "Roy.Lei.Atom@hotmail.com",
-    //"SenderEmail": "Roy.Lei.Atom@hotmail.com",
-    "SenderName": "Roy Lei",
-    "AppPassword": "pqqrwkszdodzhift"
-  },
-  // Microsoft Graph 邮箱:仅使用调用方传入的访问令牌;收件箱列表 $top 上限
-  "MicrosoftGraphMailbox": {
-    "TopMessages": 50
-  }
+  "AzureHotmail": [
+    {
+      "UserId": 208,
+      "UserName": "雷怡",
+      "TenantId": "b65b1f22-bc62-4fab-afde-5fa5dcdd6d22",
+      "ClientId": "982eb5a3-c6a9-41ac-89ab-d892e52eb58a",
+      "ClientSecret": "29C8Q~FxSA3GYOBe-g-GO_qbYE1D-dD9sleCqbXy",
+      "Tenant": "common",
+      "RedirectUri": "http://localhost:5256/api/AITest/auth/callback"
+    }
+  ]
 }

+ 62 - 8
OASystem/OASystem.Infrastructure/Tools/CommonFun.cs

@@ -188,14 +188,14 @@ public static class CommonFun
                 }
             },
             { ".xls", new List<byte[]>
-                { 
-                    new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 } 
-                } 
+                {
+                    new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }
+                }
             },
-            { ".xlsx", new List<byte[]> 
-                { 
-                    new byte[] { 0x50, 0x4B, 0x03, 0x04 } 
-                } 
+            { ".xlsx", new List<byte[]>
+                {
+                    new byte[] { 0x50, 0x4B, 0x03, 0x04 }
+                }
             }
         };
 
@@ -519,7 +519,7 @@ public static class CommonFun
         if (rateStr.Contains("|"))
         {
             string[] currencyArr = rateStr.Split("|");
-            
+
             foreach (string currency in currencyArr)
             {
                 if (!string.IsNullOrEmpty(currency) && !currency.Contains("-"))
@@ -1046,4 +1046,58 @@ public static class CommonFun
         return dict.GetValueOrDefault(key) ?? string.Empty;
     }
 
+    #region UTC 时间
+
+    /// <summary>
+    /// 跨平台获取中国时区
+    /// </summary>
+    /// <returns></returns>
+    public static TimeZoneInfo GetCstZone()
+    {
+        try
+        {
+            // Windows 环境
+            return TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
+        }
+        catch
+        {
+            // Linux / Docker 环境
+            return TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
+        }
+    }
+
+    /// <summary>
+    /// 获取 UTC 时间格式
+    /// </summary>
+    /// <param name="localDate"></param>
+    /// <returns></returns>
+    public static string ToGraphUtcString(DateTime localDate)
+    {
+        // 1. 获取中国标准时间时区 (UTC+8)
+        // 注意:Windows 下为 "China Standard Time",Linux 下通常为 "Asia/Shanghai"
+        // .NET 6+ 推荐使用 TimeZoneInfo.FindSystemTimeZoneById
+        TimeZoneInfo cstZone;
+        try
+        {
+            cstZone = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
+        }
+        catch
+        {
+            cstZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
+        }
+
+        // 2. 如果传入的 DateTime 本身没有指定时区,假定它是北京时间
+        DateTime cstTime = localDate.Kind == DateTimeKind.Unspecified
+            ? DateTime.SpecifyKind(localDate, DateTimeKind.Unspecified)
+            : localDate;
+
+        // 3. 转换为 UTC 时间
+        DateTime utcTime = TimeZoneInfo.ConvertTimeToUtc(cstTime, cstZone);
+
+        // 4. 返回 Graph API 要求的 ISO 8601 格式
+        return utcTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
+    }
+
+    #endregion
+
 }