浏览代码

20260320165338

Lyyyi 2 周之前
父节点
当前提交
5344a30e5f

+ 354 - 23
OASystem/OASystem.Api/Controllers/ResourceController.cs

@@ -2638,7 +2638,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             response.Headers.Add("Content-Type", "text/event-stream");
             response.Headers.Add("Cache-Control", "no-cache");
             response.Headers.Add("Connection", "keep-alive");
-            // 关键:禁用代理缓存,确保数据实时流向前端
             response.Headers.Add("X-Accel-Buffering", "no");
 
             // 定义流式推送匿名函数
@@ -2674,8 +2673,6 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
                     .Select(x => x.CnName).FirstAsync() ?? "-";
                 #endregion
 
-                await Task.Delay(2000);
-
                 await SendStep(20, "正在翻阅本地典籍,执行高性能并行解密...");
 
                 #region 2. 本地数据并行解密 (Performance Boost)
@@ -2944,60 +2941,63 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
         /// </summary>
         /// <param name="dto"></param>
         /// <returns></returns>
+        /// <summary>
+        /// 商邀资料AI 设置复选框选中 批量 (支持传入空数组进行重置)
+        /// </summary>
         [HttpPost]
         public async Task<IActionResult> InvitationAISetChecked([FromBody] InvitationAISetCheckedDto dto)
         {
-            // 参数校验
-            if (dto.Id < 1 || dto.CurrUserId < 1 || dto.Guids == null || !dto.Guids.Any())
+            // 1. 基础参数校验 (注意:这里移除了对 Guids.Any() 的强校验,允许空集合)
+            if (dto.Id < 1 || dto.CurrUserId < 1 || dto.Guids == null)
                 return Ok(JsonView(false, "请求参数不完整"));
 
-            // 获取主记录
+            // 2. 获取主记录
             var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>()
                 .FirstAsync(x => x.IsDel == 0 && x.Id == dto.Id);
 
             if (invAiInfo?.AiCrawledDetails == null || !invAiInfo.AiCrawledDetails.Any())
                 return Ok(JsonView(false, "数据不存在或集合为空"));
 
-            // 准备更新所需数据
+            // 3. 准备更新所需数据
             var guidSet = dto.Guids.ToHashSet();
             var now = DateTime.Now;
+
+            // 获取操作人姓名
             string operatorName = await _sqlSugar.Queryable<Sys_Users>()
-                .Where(x => x.Id == dto.CurrUserId)
-                .Select(x => x.CnName)
-                .FirstAsync() ?? "-";
+                    .Where(x => x.Id == dto.CurrUserId)
+                    .Select(x => x.CnName)
+                    .FirstAsync() ?? "-";
 
-            // 单次遍历完成 重置 + 更新
-            bool hasMatch = false;
+            // 4. 单次遍历完成 重置 + 更新
+            // 如果 guidSet 为空,循环会将所有项的 IsChecked 置为 false
             foreach (var item in invAiInfo.AiCrawledDetails)
             {
                 if (guidSet.Contains(item.Guid))
                 {
                     item.IsChecked = true;
-                    item.OperatedAt = now;
-                    item.Operator = operatorName;
-                    hasMatch = true;
                 }
                 else
                 {
                     item.IsChecked = false;
                 }
-            }
 
-            if (!hasMatch)
-                return Ok(JsonView(false, "未匹配到任何有效的邀请项"));
+                item.OperatedAt = now;
+                item.Operator = operatorName;
+            }
 
-            // 排序逻辑(按需保留:仅当需要置顶显示选中项时开启)
+            // 5. 排序逻辑
+            // 即使是全量取消选中,也可以按最后操作时间排序,让最近变动的项在前
             invAiInfo.AiCrawledDetails = invAiInfo.AiCrawledDetails
                 .OrderByDescending(x => x.OperatedAt)
                 .ToList();
 
-            // 提交数据库
-            // 注意:UpdateColumns 会序列化整个 List 覆盖原字段
-            int rowCount = await _sqlSugar.Updateable(invAiInfo)
+            // 6. 提交数据库
+            // 注意:SqlSugar 的 UpdateColumns 会序列化整个对象列表并覆盖数据库字段
+            await _sqlSugar.Updateable(invAiInfo)
                 .UpdateColumns(x => x.AiCrawledDetails)
                 .ExecuteCommandAsync();
 
-            return Ok(JsonView(true, "设置成功"));
+            return Ok(JsonView(true, guidSet.Any() ? "设置成功" : "已全部取消选中"));
         }
 
         /// <summary>
@@ -3353,6 +3353,134 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             return Ok(JsonView(false, $"AI续写失败!"));
         }
 
+        /// <summary>
+        ///  商邀资料AI 混元AI续写(SSE流式推送)
+        /// </summary>
+        /// <param name="dto"></param>
+        /// <returns></returns>
+        [HttpPost]
+        public async Task InvitationAICompleteTextStream([FromBody] InvitationAICompleteTextDto dto)
+        {
+            var response = HttpContext.Response;
+            response.Headers.Add("Content-Type", "text/event-stream");
+            response.Headers.Add("Cache-Control", "no-cache");
+            response.Headers.Add("Connection", "keep-alive");
+            response.Headers.Add("X-Accel-Buffering", "no"); // 禁用 Nginx 缓存
+
+            // 内部推送工具
+            async Task SendStep(int progress, string msg, object data = null)
+            {
+                var payload = JsonConvert.SerializeObject(new
+                {
+                    progress,
+                    message = msg,
+                    data,
+                    operatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+                });
+                await response.WriteAsync($"data: {payload}\n\n");
+                await response.Body.FlushAsync();
+            }
+
+            try
+            {
+                // 初始化检查 (Progress: 5%)
+                var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.Id == dto.Id).FirstAsync();
+                if (invAiInfo?.EntryInfo == null)
+                {
+                    await SendStep(-1, "请先设置关键字信息!");
+                    return;
+                }
+                await SendStep(10, "任务初始化完成,正在计算补齐缺口...");
+
+                // 任务拆解逻辑
+                var entryInfo = invAiInfo.EntryInfo;
+                var aiTasks = new List<CountryAIPormptInfo>();
+                int targetPerCountry = entryInfo.NeedCount;
+
+                foreach (var countryName in entryInfo.TargetCountry)
+                {
+                    var countryDataCount = invAiInfo.AiCrawledDetails.Count(x => x.Region == countryName);
+                    int aiNeedCount = targetPerCountry - countryDataCount;
+                    if (aiNeedCount > 0) aiTasks.Add(new() { Country = countryName, Count = aiNeedCount });
+                }
+
+                if (!aiTasks.Any())
+                {
+                    await SendStep(100, "数据已满额,无需续写", invAiInfo.AiCrawledDetails);
+                    return;
+                }
+
+                // 准备 AI Prompt (Progress: 20%)
+                var existingNames = new HashSet<string>(invAiInfo.AiCrawledDetails.Where(x => x.Source == 1).Select(x => x.NameCn));
+                string promptOther = $"请基于以下已存在的名称列表进行推荐,避免重复:{string.Join(", ", existingNames)}。{entryInfo.OtherConstraints}";
+                entryInfo.OtherConstraints = promptOther; // 将去重提示注入 entryInfo,确保 Prompt 构建时包含该信息
+
+                // 构建 Question 
+                string question = BuildHunyuanPrompt(aiTasks, entryInfo);
+
+                await SendStep(30, "AI 正在深度检索跨境商邀数据,请稍候...");
+
+                // 调用 AI (Progress: 30% - 80%)
+                // 注意:这里如果能换成流式接口(ChatCompletionsStream)会更好,
+                // 如果依然用非流式,这里会有一段较长的等待。
+                string aiRawResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(question);
+
+                await SendStep(85, "数据已捕获,正在进行格式校验与去重...");
+
+                // 5. 解析与清洗数据
+                var hunyuanAIInvDatas = ProcessAIResponse(aiRawResponse);
+                string operatorName = await _sqlSugar.Queryable<Sys_Users>().Where(x => x.Id == dto.CurrUserId).Select(x => x.CnName).FirstAsync() ?? "-";
+
+                foreach (var x in hunyuanAIInvDatas)
+                {
+                    x.Guid = Guid.NewGuid().ToString("N");
+                    x.Source = 1;
+                    x.Operator = operatorName;
+                    x.OperatedAt = DateTime.Now;
+                }
+
+                // 6. 数据库操作 (Progress: 95%)
+                invAiInfo.AiCrawledDetails.AddRange(hunyuanAIInvDatas);
+                invAiInfo.AiCrawledDetails = invAiInfo.AiCrawledDetails.OrderByDescending(x => x.OperatedAt).ToList();
+
+                var update = await _sqlSugar.Updateable(invAiInfo).UpdateColumns(x => x.AiCrawledDetails).ExecuteCommandAsync();
+
+                if (update > 0)
+                {
+                    await SendStep(100, $"AI 续写成功!新增 {hunyuanAIInvDatas.Count} 条数据", invAiInfo.AiCrawledDetails);
+                }
+                else
+                {
+                    await SendStep(-1, "数据库更新失败");
+                }
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "SSE 续写异常");
+                await SendStep(-1, $"炼金炸炉:{ex.Message}");
+            }
+            finally
+            {
+                await response.Body.FlushAsync();
+                await response.CompleteAsync(); // 彻底关闭连接
+            }
+        }
+
+        private List<InvitationAIInfo> ProcessAIResponse(string response)
+        {
+            if (string.IsNullOrWhiteSpace(response)) return new List<InvitationAIInfo>();
+            string cleanJson = response.Trim();
+            if (cleanJson.Contains("```json"))
+            {
+                cleanJson = Regex.Match(cleanJson, @"```json([\s\S]*?)```").Groups[1].Value.Trim();
+            }
+            else if (cleanJson.Contains("```"))
+            {
+                cleanJson = Regex.Match(cleanJson, @"```([\s\S]*?)```").Groups[1].Value.Trim();
+            }
+            return JsonConvert.DeserializeObject<List<InvitationAIInfo>>(cleanJson) ?? new List<InvitationAIInfo>();
+        }
+
         /// <summary>
         /// 商邀资料AI 文件生成(基于混元AI已爬取数据进行格式化输出,供用户下载使用)
         /// </summary>
@@ -3804,6 +3932,209 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
             return Ok(JsonView(true, msgSb.ToString()));
         }
 
+        /// <summary>
+        /// 商邀资料AI 生成邮件(SSE流式推送)
+        /// </summary>
+        /// <returns></returns>
+        [HttpPost]
+        public async Task InvitationAIGenerateEmailStream([FromBody] InvitationAIGenerateEmailDto dto)
+        {
+            var response = HttpContext.Response;
+            response.Headers.Add("Content-Type", "text/event-stream");
+            response.Headers.Add("Cache-Control", "no-cache");
+            response.Headers.Add("Connection", "keep-alive");
+            response.Headers.Add("X-Accel-Buffering", "no");
+
+            async Task SendStep(int progress, string msg, object data = null)
+            {
+                var payload = JsonConvert.SerializeObject(new
+                {
+                    progress,
+                    message = msg,
+                    data,
+                    operatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+                });
+                await response.WriteAsync($"data: {payload}\n\n");
+                await response.Body.FlushAsync();
+            }
+
+            try
+            {
+                // 1. 基础校验与资源加载 (Progress: 5%)
+                if (dto.Id < 1 || dto.Guids == null || !dto.Guids.Any())
+                {
+                    await SendStep(-1, "请求参数不完整,请选择单位");
+                    return;
+                }
+
+                var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.Id == dto.Id).FirstAsync();
+                if (invAiInfo?.AiCrawledDetails == null)
+                {
+                    await SendStep(-1, "基础商邀资料不存在");
+                    return;
+                }
+
+                await SendStep(10, "正在分析考察团组背景与访问意图...");
+
+                // 2. 准备 AI 上下文数据 (Progress: 15%)
+                var clientInfoSources = invAiInfo.AiCrawledDetails.Where(x => dto.Guids.Contains(x.Guid)).ToList();
+                var clientInfosForAI = clientInfoSources.Select(x => new AICreateEmailInfo()
+                {
+                    Guid = x.Guid,
+                    NameCn = x.NameCn,
+                    Scope = x.Scope
+                }).ToList();
+
+                var groupInfo = await _sqlSugar.Queryable<Grp_DelegationInfo>()
+                    .Where(x => x.IsDel == 0 && x.Id == invAiInfo.GroupId)
+                    .Select(x => new { x.TeamName, x.VisitPurpose, x.VisitDate })
+                    .FirstAsync();
+
+                string operatorName = await _sqlSugar.Queryable<Sys_Users>()
+                    .Where(x => x.Id == dto.CurrUserId).Select(x => x.CnName).FirstAsync() ?? "-";
+
+                // 3. 构建 Prompt 并调用 AI (Progress: 25%)
+                await SendStep(30, $"首席联络官已就位,正在为 {clientInfosForAI.Count} 家单位撰写定制化正式邮件...");
+
+                // 此处沿用你原有的庞大 Prompt 逻辑
+                string pormpt = $@"
+# Role
+你是一位精通国际政企关系的【首席联络官】。你具备极强的行业分析能力,能通过 [SourceEntity] 的名称自动检索并推导其行政职能与行业地位,并以此撰写具有战略高度、语调优雅的正式商务邮件。
+
+# Intelligence Task: Source Profiling
+在生成邮件前,请先执行以下逻辑:
+1. **职能推导**:基于 [SourceEntity] 的名称,自动识别其在所属领域的具体行政职能与政策影响力。
+2. **战略对齐**:将其职能与全球大趋势(如:Sustainable Urbanization, Digital Transformation, Carbon Neutrality)挂钩,作为邮件第二段的叙事背景。
+
+# [RICH_TEXT_STANDARDS_FOR_QUILL]
+- **Prohibited Tags**: 绝对禁止包含 <html>, <body>, <head>, <!DOCTYPE>, <hr>, <style>。
+- **Whitelisted Tags**: 仅允许使用 <h3> (用于主题), <p> (用于段落), <ul>, <li>, <strong>, <br>。
+- **Structural Integrity**: 
+    - 严禁出现裸露文本,所有段落必须由 <p> 包裹。
+    - 列表必须使用标准 <ul><li> 嵌套结构。
+- **No Markdown**: 严禁在 Content 中出现 ###, **, --- 等 Markdown 符号。
+
+# Reference Model (Few-Shot Example)
+在生成时,请严格参考以下范例的【语气】、【五段式结构】和【外交辞令】:
+- Subject: Official Study Visit Inquiry – [Delegation Name] ([Date])
+- Para 1: ""On behalf of [SourceEntity], I am writing to formally propose...""
+- Para 2: ""As a pivotal megacity [AI根据Source地位补全]... we recognize [Target]’s leadership in...""
+- Para 3: ""We are particularly keen to explore... [基于Target经营范围推导的3个技术点]...""
+- Para 4: ""We propose a 2–3 day visit... during the week of [Logistics]...""
+- Para 5: ""Enclosed please find the [PascalCase_File]...""
+
+# Task
+根据 [TargetList] 中每个单位的【经营范围】,为 [SourceEntity] 生成独立的英文访问请求邮件。
+
+# Inputs
+- [SourceEntity]: [{invAiInfo.EntryInfo?.OriginUnit ?? ""}]
+- [VisitPurpose]: [{groupInfo?.VisitPurpose}]
+- [TargetList]: [{JsonConvert.SerializeObject(clientInfosForAI)}]
+- [Logistics]: [{groupInfo?.VisitDate.ToString("yyyy-MM-dd")}]
+
+# Execution Logic (Chain of Thought)
+1. **Scope-to-Focus Analysis**: 
+   - 深入分析每个 Target 的【经营范围】。
+   - 自动推导 3 个与 [VisitPurpose] 高度对齐的专业考察点(如:Policy Frameworks, Technical Standards, Operational Case Studies)。
+2. **Modular Drafting**: 
+   - 必须严格遵循参考范例的 5 段式逻辑。
+   - 针对不同职能属性(监管/技术/运营)动态微调邮件主题(Subject)。
+3. **No Personnel Reference**: 严禁提及“名单、人数、成员、审核中”等任何具体人员信息,保持机构对等对话的高度。
+
+# Constraints & Standards
+- **Tone**: Formal, Strategic, and Executive (庄重、具战略高度、执行力强)。
+- **Naming Protocol**: 附件引用统一使用 `JointVisitAgenda`, `StrategicInquiryBrief` (PascalCase)。
+- **Escaping**: 必须对 HTML 内部的所有双引号进行严格转义(\""),确保 JSON 字符串合法。
+- **Output Format**: 仅输出一个合规的 JSON 数组,不包含任何解释性文字。
+- **Field Mapping**:
+  - `Guid`: 原样保留。
+  - `NameCn`: 原样保留。
+  - `Scope`: 原样保留。
+  - `Subject`: 动态生成的英文主题。
+  - `Content`: 包含 QUILL的英文正文。
+
+# JSON Structure Template
+[
+  {{{{
+    ""Guid"": ""ID"",
+    ""NameCn"": ""Entity Name"",
+    ""Scope"": ""Original Scope Description"",
+    ""Subject"": ""Dynamic English Subject Line"",
+    ""Content"": ""<h3>Subject: ...</h3><p>Dear Leadership Team of <strong>[Target]</strong>,</p><p>On behalf of <strong>[SourceEntity]</strong>, I am writing to propose...</p><ul><li><strong>Focus Area:</strong> Technical analysis of...</li></ul><p>Sincerely,<br><strong>Office of the Chief Liaison</strong><br>[SourceEntity]</p>""
+  }}}}
+]";
+
+                // 调用 AI (此处为阻塞式等待 AI 结果,若混元支持 Stream 可进一步拆解)
+                string aiResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(pormpt);
+
+                await SendStep(80, "邮件初稿已生成,正在进行 HTML 格式校验与转义处理...");
+
+                // 4. 解析结果 (Progress: 85%)
+                var hunyuanAIEmailDatas = new List<AICreateEmailInfo>();
+                if (!string.IsNullOrWhiteSpace(aiResponse))
+            {
+                    // 预处理:过滤 AI 可能返回的 Markdown 标记
+                    string cleanJson = aiResponse.Trim();
+                    if (cleanJson.StartsWith("```json"))
+                        cleanJson = cleanJson.Substring(7, cleanJson.Length - 10).Trim();
+                    else if (cleanJson.StartsWith("```"))
+                        cleanJson = cleanJson.Substring(3, cleanJson.Length - 6).Trim();
+
+                    try
+                    {
+                        // 解析并注入 Source 标识
+                        hunyuanAIEmailDatas = JsonConvert.DeserializeObject<List<AICreateEmailInfo>>(cleanJson);
+
+                    }
+                    catch (JsonException ex)
+                    {
+                        // 记录日志并考虑 fallback 策略
+                        _logger.LogError(ex, "Hunyuan AI 响应解析失败。原始数据:{Response}", aiResponse);
+                    }
+                }
+                if (hunyuanAIEmailDatas == null || !hunyuanAIEmailDatas.Any())
+                {
+                    await SendStep(-1, "AI 格式解析失败,请尝试重新生成");
+                    return;
+                }
+
+                // 5. 更新数据模型 (Progress: 90%)
+                foreach (var client in clientInfoSources)
+                {
+                    var aiEmail = hunyuanAIEmailDatas.FirstOrDefault(x => x.Guid == client.Guid);
+                    if (aiEmail != null)
+                    {
+                        client.EmailInfo.Status = 2; // 已生成
+                        client.EmailInfo.EmailTitle = aiEmail.Subject;
+                        client.EmailInfo.EmailContent = aiEmail.Content;
+                        client.EmailInfo.Operator = operatorName;
+                        client.EmailInfo.OperatedAt = DateTime.Now;
+                    }
+                }
+
+                // 6. 数据库持久化 (Progress: 95%)
+                // 采用增量更新策略,避免直接 Where 过滤掉其他未选中的数据
+                var update = await _sqlSugar.Updateable(invAiInfo).UpdateColumns(x => x.AiCrawledDetails).ExecuteCommandAsync();
+                if (update > 0)
+                {
+                    await SendStep(100, "邮件全部生成完毕并已存入团组资料库", invAiInfo.AiCrawledDetails);
+                }
+                else
+                {
+                    await SendStep(-1, "数据库写入失败,请联系管理员");
+                }
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "邮件生成异常");
+                await SendStep(-1, $"生成失败:{ex.Message}");
+            }
+            finally
+            {
+                await response.CompleteAsync();
+            }
+        }
+
         /// <summary>
         /// 商邀资料AI 邮件保存
         /// </summary>

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

@@ -0,0 +1,45 @@
+
+using System.Net;
+using System.Net.Mail;
+
+namespace OASystem.API.OAMethodLib.HotmailEmail
+{
+    /// <summary>
+    /// Hotemail 服务
+    /// </summary>
+    public class HotmailEmailService
+    {
+        private readonly string _hotmailEmail = "your-email@hotmail.com";
+        private readonly string _appPassword = "your-app-specific-password"; // 不是你的登录密码,是应用专用密码
+
+        public async Task SendEmailAsync(string targetEmail, string subject, string body)
+        {
+            using (var client = new SmtpClient("smtp.office365.com", 587))
+            {
+                client.EnableSsl = true;
+                client.UseDefaultCredentials = false;
+                client.Credentials = new NetworkCredential(_hotmailEmail, _appPassword);
+
+                var mailMessage = new MailMessage
+                {
+                    From = new MailAddress(_hotmailEmail, "商邀联络官"),
+                    Subject = subject,
+                    Body = body,
+                    IsBodyHtml = true // 如果内容是 HTML 格式
+                };
+
+                mailMessage.To.Add(targetEmail);
+
+                try
+                {
+                    await client.SendMailAsync(mailMessage);
+                }
+                catch (Exception ex)
+                {
+                    // 记录日志:ex.Message
+                    throw;
+                }
+            }
+        }
+    }
+}