2 Commits 130951c2d6 ... b680d75938

Auteur SHA1 Message Date
  Lyyyi b680d75938 1111 il y a 1 semaine
  Lyyyi cacc3d5aab hotmail 邮件发送构建服务,商邀AI发送邮件 il y a 1 semaine

+ 137 - 35
OASystem/OASystem.Api/Controllers/AITestController.cs

@@ -1,13 +1,13 @@
-using Microsoft.AspNetCore.Mvc;
+using Flurl.Http.Configuration;
 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.Domain.ViewModels.QiYeWeChat;
-using System.IO;
-using System.Threading.Tasks;
-using TencentCloud.Hunyuan.V20230901.Models;
+using System.IdentityModel.Tokens.Jwt;
+using System.Text.Json;
+using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
 using OASystem.RedisRepository;
 
 namespace OASystem.API.Controllers
@@ -21,24 +21,20 @@ 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 IMicrosoftGraphMailboxService _microsoftGraphMailboxService;
-
-        public AITestController(
-            IHunyuanService hunyuanService,
-            IDoubaoService doubaoService,
-            ILogger<AITestController> logger,
-            IQiYeWeChatApiService qiYeWeChatApiService,
-            IHotmailEmailService hotmailEmailService,
-            IMicrosoftGraphMailboxService microsoftGraphMailboxService)
+        private readonly System.Net.Http.IHttpClientFactory _httpClientFactory;
+        private readonly HotmailService _hotmailService;
+
+        public AITestController(IHunyuanService hunyuanService, IDoubaoService doubaoService, ILogger<AITestController> logger, IQiYeWeChatApiService qiYeWeChatApiService, HotmailService hotmailService, System.Net.Http.IHttpClientFactory httpClientFactory, IConfiguration config)
         {
             _hunyuanService = hunyuanService;
             _doubaoService = doubaoService;
             _logger = logger;
             _qiYeWeChatApiService = qiYeWeChatApiService;
-            _hotmailEmailService = hotmailEmailService;
-            _microsoftGraphMailboxService = microsoftGraphMailboxService;
+            _hotmailService = hotmailService;
+            _httpClientFactory = httpClientFactory;
+            _config = config;
         }
 
         #region 企业微信发送邮件测试
@@ -334,31 +330,137 @@ 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()
+        {
+
+            // 1. 获取当前北京时间 (CST)
+            var cstZone = CommonFun.GetCstZone();
+            var nowInCst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, cstZone);
+
+            // 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
+
+
+           var res = await _hotmailService.GetMergedMessagesAsync(
+               new List<string>() { "925554512@qq.com" },
+               yesterdayStart,
+               yesterdayEnd
+               );
+
+            return StatusCode(200, res);
+        }
+
+
+
+
+        #region 微软 auth
+
+        [HttpGet("auth/url")]
+        public IActionResult GetAuthUrl()
         {
-            if (string.IsNullOrEmpty(request.Email))
-                return BadRequest("邮箱地址不能为空");
+            var clientId = _config["AzureHotmail:ClientId"];
+            var redirectUri = _config["AzureHotmail:RedirectUri"]; // 需在 Azure Portal 注册
+            var scope = Uri.EscapeDataString("offline_access Mail.Read Mail.Send User.Read");
 
-            // 调用服务
-            bool isSent = await _hotmailEmailService.SendEmailAsync(
-                request.Email,
-                "OASystem 业务提醒",
-                $"<p>您有一条新的待办事项:<b>{request.Content}</b></p>"
-            );
+            var url = $"https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id={clientId}&response_type=code&redirect_uri={redirectUri}&response_mode=query&scope={scope}&state=alchemist";
+
+            return Ok(new { authUrl = url });
+        }
+
+        [HttpGet("auth/callback")]
+        public async Task<IActionResult> HandleCallback([FromQuery] string code)
+        {
+            if (string.IsNullOrEmpty(code)) return BadRequest("授权码无效");
+
+            // 1. 换取令牌
+            var httpClient = _httpClientFactory.CreateClient();
+            var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
+            {
+                { "client_id", _config["AzureHotmail:ClientId"] },
+                { "client_secret", _config["AzureHotmail:ClientSecret"] },
+                { "code", code },
+                { "redirect_uri", _config["AzureHotmail:RedirectUri"] },
+                { "grant_type", "authorization_code" }
+            });
 
-            if (isSent)
-                return Ok(new { code = 200, msg = "发送成功" });
+            var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", tokenRequest);
+            var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
 
-            return StatusCode(500, "邮件发送失败,请检查服务器配置或应用密码");
+            if (!response.IsSuccessStatusCode) return BadRequest(json.RootElement.ToString());
+
+            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();
+
+            // 2. 自动识别账户身份 【核心重构】:不再手动解析 JWT,而是请求 Graph 的 /me 接口
+            string userEmail = await GetEmailFromGraphApiAsync(accessToken);
+
+
+            // 3. 炼金产物:构造并存入 Redis
+            var userToken = new UserToken
+            {
+                Email = userEmail,
+                AccessToken = accessToken,
+                RefreshToken = refreshToken,
+                ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn)
+            };
+
+            // 存入 Redis (使用我们之前的 RedisKeyPrefix: "MailAlchemy:Token:")
+            var redisKey = $"MailAlchemy:Token:{userEmail}";
+            await RedisRepository.RedisFactory.CreateRedisRepository().StringSetAsync<string>(redisKey, System.Text.Json.JsonSerializer.Serialize(userToken), TimeSpan.FromDays(90));
+
+            return Ok(new
+            {
+                status = "Success",
+                account = userEmail,
+                message = "该个人账户已成功集成并启用分布式存储"
+            });
         }
 
-        // 定义请求实体
-        public class EmailRequest
+        private async Task<string> GetEmailFromGraphApiAsync(string accessToken)
         {
-            public string Email { get; set; } = string.Empty;
-            public string Content { get; set; } = string.Empty;
+            var httpClient = _httpClientFactory.CreateClient();
+            // 使用 AccessToken 调用 Graph API 的个人信息接口
+            httpClient.DefaultRequestHeaders.Authorization =
+                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
+
+            var response = await httpClient.GetAsync("https://graph.microsoft.com/v1.0/me");
+            if (!response.IsSuccessStatusCode)
+                throw new Exception("无法通过 Graph API 获取用户信息");
+
+            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 地址");
         }
 
         #endregion

+ 133 - 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,125 @@ 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 回调地址
+        /// </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. 从 state 中解析出真正的 userId
+            if (!int.TryParse(state, out int userId))
+            {
+                return BadRequest("非法的 state 标识");
+            }
+
+            var config = await _hotmailService.GetUserMailConfig(userId);
+            if (config == null)
+            {
+                return BadRequest("state标识无效");
+            }
+
+            // 1. 换取令牌
+            var httpClient = _httpClientFactory.CreateClient();
+            var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
+            {
+                { "client_id",config.ClientId },
+                { "client_secret", config.ClientSecret },
+                { "code", code },
+                { "redirect_uri", config.RedirectUri },
+                { "grant_type", "authorization_code" }
+            });
+
+            var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", tokenRequest);
+            var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+
+            if (!response.IsSuccessStatusCode) return BadRequest(json.RootElement.ToString());
+
+            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();
+
+            // 2. 自动识别账户身份 【核心重构】:不再手动解析 JWT,而是请求 Graph 的 /me 接口
+            string userEmail = await GetEmailFromGraphApiAsync(accessToken);
+
+            // 3. 构造并存入 Redis
+            var userToken = new UserToken
+            {
+                Email = userEmail,
+                AccessToken = accessToken,
+                RefreshToken = refreshToken,
+                ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn)
+            };
+
+            // 存入 Redis
+            var redisKey = $"MailAlchemy:Token:{userEmail}";
+            await RedisRepository.RedisFactory.CreateRedisRepository().StringSetAsync<string>(redisKey, System.Text.Json.JsonSerializer.Serialize(userToken), TimeSpan.FromDays(90));
+
+            return Ok(new
+            {
+                status = "Success",
+                account = userEmail,
+                message = "该个人账户已成功集成并启用分布式存储"
+            });
+        }
+
+        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);
+
+            var response = await httpClient.GetAsync("https://graph.microsoft.com/v1.0/me");
+            if (!response.IsSuccessStatusCode)
+                throw new Exception("无法通过 Graph API 获取用户信息");
+
+            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 地址");
+        }
+
+        #endregion
+
+
         /// <summary>
         /// 测试auth
         /// </summary>

+ 28 - 26
OASystem/OASystem.Api/Controllers/ResourceController.cs

@@ -8,6 +8,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 +26,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 +67,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 +85,7 @@ namespace OASystem.API.Controllers
             ILogger<ResourceController> logger,
             IHunyuanService hunyuanService,
             IQiYeWeChatApiService qiYeWeChatApiService,
+            HotmailService hotmailService,
             SqlSugarClient sqlSugar,
             CarDataRepository carDataRep,
             LocalGuideDataRepository localGuideDataRep,
@@ -111,6 +115,7 @@ namespace OASystem.API.Controllers
             _logger = logger;
             _hunyuanService = hunyuanService;
             _qiYeWeChatApiService = qiYeWeChatApiService;
+            _hotmailService = hotmailService;
             _sqlSugar = sqlSugar;
             _carDataRep = carDataRep;
             _localGuideDataRep = localGuideDataRep;
@@ -4353,7 +4358,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 +4367,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 +4391,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 +4400,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 +4426,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 +4447,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 +4457,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
 

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

@@ -0,0 +1,368 @@
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Graph;
+using Microsoft.Graph.Models;
+using Microsoft.Graph.Models.ODataErrors;
+using Microsoft.Kiota.Abstractions.Authentication;
+using StackExchange.Redis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using static OASystem.API.OAMethodLib.Hotmail.HotmailService;
+
+namespace OASystem.API.OAMethodLib.Hotmail
+{
+    public class HotmailService
+    {
+        private readonly IHttpClientFactory _httpClientFactory;
+        private readonly IConfiguration _config;
+        private  readonly SqlSugarClient _sqlSugar;
+        private const string RedisKeyPrefix = "MailAlchemy:Token:";
+
+        public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar)
+        {
+            _httpClientFactory = httpClientFactory;
+            _config = config;
+            _sqlSugar = sqlSugar;
+        }
+
+        /// <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. 获取用户信息,支持空合并优化
+            var userName = await _sqlSugar.Queryable<Sys_Users>()
+                .Where(x => x.IsDel == 0 && x.Id == userId)
+                .Select(x => x.CnName)
+                .FirstAsync() ?? "未知用户";
+
+            var userConfig = await GetUserMailConfig(userId);
+            if (userConfig == null)
+                return (-1, $"[{userName}] Hotmail 基础配置缺失");
+
+            if (string.IsNullOrWhiteSpace(userConfig.UserName))
+                return (-1, $"[{userName}] 未配置邮箱账号");
+
+            // 2. 验证状态检查
+            var redisKey = $"{RedisKeyPrefix}{userConfig.UserName.Trim()}";
+            var cachedJson = await RedisRepository.RedisFactory.CreateRedisRepository().StringGetAsync<string>(redisKey);
+
+            // 修正:已通过验证应返回 0
+            if (!string.IsNullOrWhiteSpace(cachedJson))
+                return (0, $"{userName} 已通过验证,无需重复操作");
+
+            // 3. 授权参数深度净化
+            var clientId = userConfig.ClientId?.Trim();
+            var redirectUri = userConfig.RedirectUri?.Trim().Replace("\r", "").Replace("\n", ""); // 彻底剔除换行符
+
+            //var redirectUri = "http://localhost:5256/api/microsoft/auth/callback";
+
+            if (string.IsNullOrWhiteSpace(clientId))
+                return (-1, $"[{userName}] 客户端 ID (ClientId) 未配置");
+
+            if (string.IsNullOrWhiteSpace(redirectUri))
+                return (-1, $"[{userName}] 回调地址 (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" },
+                { "scope", "offline_access Mail.Read Mail.Send User.Read" },
+                { "state", userId.ToString() }
+                //{ "state", Guid.NewGuid().ToString("N") }
+            };
+
+            // QueryHelpers 会处理 URL 编码,确保 RedirectUri 不会被二次破坏
+            var authUrl = QueryHelpers.AddQueryString(authEndpoint, queryParams);
+
+            return (1, authUrl);
+        }
+
+        /// <summary>
+        /// 获取多个账户的合并收件箱 (并行处理)
+        /// </summary>
+        public async Task<List<MailDto>> GetMergedMessagesAsync(List<string> emails, DateTime cstStart, DateTime cstEnd)
+        {
+            var tasks = emails.Select(async email =>
+            {
+                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.Orderby = new[] { "receivedDateTime desc" };
+                    });
+
+                    return response?.Value?.Select(m => new MailDto
+                    {
+                      
+                        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>();
+                }
+                catch (Exception ex)
+                {
+                    Console.WriteLine($"[Error] Account {email}: {ex.Message}");
+                    return Enumerable.Empty<MailDto>();
+                }
+            });
+
+            var results = await Task.WhenAll(tasks);
+            return results.SelectMany(x => x).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>
+        /// 获取邮箱配置信息
+        /// </summary>
+        /// <returns></returns>
+        public async Task<HotmailConfig?> GetUserMailConfig(int userId) 
+        {
+            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);
+                var userConfig = allConfigs?.FirstOrDefault(x => x.UserId == userId);
+                return userConfig;
+            }
+            catch (Exception)
+            {
+                return null;
+            }
+        }
+
+        /// <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.");
+
+            var token = System.Text.Json.JsonSerializer.Deserialize<UserToken>(cachedJson!)!;
+
+            // 令牌过期预校验 (提前 5 分钟)
+            if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5))
+            {
+                token = await RefreshAndSaveTokenAsync(token);
+            }
+
+            var authProvider = new BaseBearerTokenAuthenticationProvider(new StaticTokenProvider(token.AccessToken));
+            return new GraphServiceClient(authProvider);
+        }
+
+        public async Task<UserToken> RefreshAndSaveTokenAsync(UserToken oldToken)
+        {
+            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 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.");
+
+            using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+            var root = doc.RootElement;
+
+            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())
+            };
+
+            // 存入 Redis,持久化 90 天(RefreshToken 的典型寿命)
+            await RedisRepository.RedisFactory.CreateRedisRepository().StringSetAsync<string>($"{RedisKeyPrefix}{oldToken.Email}", System.Text.Json.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 class MailDto
+        {
+            [JsonPropertyName("messageId")] public string? MessageId { get; set; }
+            [JsonPropertyName("subject")] public string? Subject { get; set; }
+            [JsonPropertyName("from")] public string? From { get; set; }
+            [JsonPropertyName("to")] public string? To { get; set; }
+            [JsonPropertyName("content")] public string? Content { get; set; }
+            [JsonPropertyName("receivedTime")] public DateTimeOffset? ReceivedTime { get; set; }
+            [JsonPropertyName("source")] public string? Source { get; set; }
+        }
+        #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);
-    }
-}

+ 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" />

+ 2 - 2
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;
@@ -597,7 +597,7 @@ builder.Services.TryAddSingleton(typeof(CommonService));
 #endregion
 
 #region hotmail
-builder.Services.AddTransient<IHotmailEmailService, HotmailEmailService>();
+builder.Services.AddSingleton<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
+
 }