|
@@ -2628,63 +2628,42 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
[HttpPost]
|
|
[HttpPost]
|
|
|
public async Task InvitationAISearchStreamProgress([FromBody] InvitationAISearchDto dto)
|
|
public async Task InvitationAISearchStreamProgress([FromBody] InvitationAISearchDto dto)
|
|
|
{
|
|
{
|
|
|
- // 强制关闭响应缓冲
|
|
|
|
|
- var syncIOFeature = HttpContext.Features.Get<IHttpBodyControlFeature>();
|
|
|
|
|
- if (syncIOFeature != null) syncIOFeature.AllowSynchronousIO = true;
|
|
|
|
|
-
|
|
|
|
|
- var response = HttpContext.Response;
|
|
|
|
|
-
|
|
|
|
|
- // --- SSE 核心协议头配置 ---
|
|
|
|
|
- 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 message, object data = null)
|
|
|
|
|
- {
|
|
|
|
|
- var settings = new JsonSerializerSettings
|
|
|
|
|
- {
|
|
|
|
|
- ContractResolver = new CamelCasePropertyNamesContractResolver() // 强制小驼峰转换
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- var payload = JsonConvert.SerializeObject(new { progress, message, data }, settings);
|
|
|
|
|
- var bytes = Encoding.UTF8.GetBytes($"data: {payload}\n\n");
|
|
|
|
|
-
|
|
|
|
|
- await response.Body.WriteAsync(bytes, 0, bytes.Length);
|
|
|
|
|
- await response.Body.FlushAsync(); // 立即清空缓冲区发送
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ HttpContext.InitializeSse();
|
|
|
|
|
|
|
|
try
|
|
try
|
|
|
{
|
|
{
|
|
|
- await SendStep(5, "正在加载配置参数...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(5, "正在加载配置...");
|
|
|
|
|
+
|
|
|
|
|
+ #region 1. 异步并行化验证 (Performance Boost)
|
|
|
|
|
+ var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.IsDel == 0 && x.Id == dto.Id).FirstAsync();
|
|
|
|
|
+ var operatorName = await _sqlSugar.Queryable<Sys_Users>()
|
|
|
|
|
+ .Where(x => x.IsDel == 0 && x.Id == dto.CurrUserId)
|
|
|
|
|
+ .Select(x => x.CnName).FirstAsync();
|
|
|
|
|
|
|
|
- #region 1. 参数与权限验证
|
|
|
|
|
- var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.Id == dto.Id).FirstAsync();
|
|
|
|
|
if (invAiInfo?.EntryInfo == null)
|
|
if (invAiInfo?.EntryInfo == null)
|
|
|
{
|
|
{
|
|
|
- await SendStep(-1, "未找到有效的关键字配置信息。");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "未找到有效的关键字配置。");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var entryInfo = invAiInfo.EntryInfo;
|
|
var entryInfo = invAiInfo.EntryInfo;
|
|
|
- var operatorName = await _sqlSugar.Queryable<Sys_Users>()
|
|
|
|
|
- .Where(x => x.IsDel == 0 && x.Id == dto.CurrUserId)
|
|
|
|
|
- .Select(x => x.CnName).FirstAsync() ?? "-";
|
|
|
|
|
|
|
+ var targetCountrySet = new HashSet<string>(entryInfo.TargetCountry);
|
|
|
#endregion
|
|
#endregion
|
|
|
|
|
|
|
|
- await SendStep(20, "正在翻阅本地典籍,执行高性能并行解密...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(20, "正在解析本地典籍 (并行解密)...");
|
|
|
|
|
|
|
|
- #region 2. 本地数据并行解密 (Performance Boost)
|
|
|
|
|
|
|
+ #region 2. 内存计算优化
|
|
|
|
|
+ // 仅查询必要字段,减少 DataReader 压力
|
|
|
var rawLocalDatas = await _sqlSugar.Queryable<Res_InvitationOfficialActivityData>()
|
|
var rawLocalDatas = await _sqlSugar.Queryable<Res_InvitationOfficialActivityData>()
|
|
|
.Where(x => x.IsDel == 0)
|
|
.Where(x => x.IsDel == 0)
|
|
|
|
|
+ .Select(x => new { x.Country, x.UnitName, x.Address, x.Field, x.Contact, x.Tel, x.Email })
|
|
|
.ToListAsync();
|
|
.ToListAsync();
|
|
|
|
|
|
|
|
- // 使用 PLINQ 并行解密,极大提升解密密集型任务速度
|
|
|
|
|
|
|
+ // PLINQ 核心炼金:利用多核并行解密
|
|
|
var allDecrypted = rawLocalDatas.AsParallel().Select(item => new InvitationAIInfo
|
|
var allDecrypted = rawLocalDatas.AsParallel().Select(item => new InvitationAIInfo
|
|
|
{
|
|
{
|
|
|
Guid = Guid.NewGuid().ToString("N"),
|
|
Guid = Guid.NewGuid().ToString("N"),
|
|
|
- Source = 0,
|
|
|
|
|
|
|
+ Source = 0, // 标识来源:本地
|
|
|
Region = AesEncryptionHelper.Decrypt(item.Country),
|
|
Region = AesEncryptionHelper.Decrypt(item.Country),
|
|
|
NameCn = AesEncryptionHelper.Decrypt(item.UnitName),
|
|
NameCn = AesEncryptionHelper.Decrypt(item.UnitName),
|
|
|
Address = AesEncryptionHelper.Decrypt(item.Address),
|
|
Address = AesEncryptionHelper.Decrypt(item.Address),
|
|
@@ -2696,161 +2675,272 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
Operator = operatorName,
|
|
Operator = operatorName,
|
|
|
}).ToList();
|
|
}).ToList();
|
|
|
|
|
|
|
|
- // 筛选目标国家并计算 AI 缺口
|
|
|
|
|
|
|
+ // 筛选符合国家的本地数据
|
|
|
|
|
+ var matchedCountries = allDecrypted.Where(x => targetCountrySet.Contains(x.Region)).ToList();
|
|
|
|
|
+ #endregion
|
|
|
|
|
+
|
|
|
|
|
+ #region 3. 混元 AI 协同炼金 (双阶段)
|
|
|
|
|
+
|
|
|
|
|
+ // --- 阶段 A: 行业匹配分析 ---
|
|
|
|
|
+ if (matchedCountries.Any())
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(40, $"AI 正在执行 {entryInfo.TargetCountry.Count} 国的行业契合度分析...");
|
|
|
|
|
+ string industryQuestion = BuildIndustryPrompt(entryInfo, matchedCountries);
|
|
|
|
|
+ string industryRaw = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(industryQuestion);
|
|
|
|
|
+
|
|
|
|
|
+ var industryMatches = CleanAndParseJson<List<IndustryMatchResult>>(industryRaw) ?? new();
|
|
|
|
|
+ var matchedNames = new HashSet<string>(industryMatches.Select(x => x.TargetUnitName));
|
|
|
|
|
+ // 重新过滤:仅保留 AI 认为匹配的本地数据
|
|
|
|
|
+ matchedCountries = matchedCountries.Where(x => matchedNames.Contains(x.NameCn)).Distinct().ToList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // --- 阶段 B: 差额补全 (Gap Filling) ---
|
|
|
var localInvDatas = new List<InvitationAIInfo>();
|
|
var localInvDatas = new List<InvitationAIInfo>();
|
|
|
var aiTasks = new List<CountryAIPormptInfo>();
|
|
var aiTasks = new List<CountryAIPormptInfo>();
|
|
|
- int targetPerCountry = entryInfo.NeedCount;
|
|
|
|
|
|
|
|
|
|
- foreach (var countryName in entryInfo.TargetCountry)
|
|
|
|
|
|
|
+ foreach (var country in entryInfo.TargetCountry)
|
|
|
{
|
|
{
|
|
|
- var countryMatched = allDecrypted.Where(x => x.Region == countryName).Take(targetPerCountry).ToList();
|
|
|
|
|
- localInvDatas.AddRange(countryMatched);
|
|
|
|
|
|
|
+ var countryData = matchedCountries.Where(x => x.Region == country).Take(entryInfo.NeedCount).ToList();
|
|
|
|
|
+ localInvDatas.AddRange(countryData);
|
|
|
|
|
|
|
|
- int gap = targetPerCountry - countryMatched.Count;
|
|
|
|
|
- if (gap > 0) aiTasks.Add(new() { Country = countryName, Count = gap });
|
|
|
|
|
|
|
+ int gap = entryInfo.NeedCount - countryData.Count;
|
|
|
|
|
+ if (gap > 0) aiTasks.Add(new() { Country = country, Count = gap });
|
|
|
}
|
|
}
|
|
|
- #endregion
|
|
|
|
|
|
|
|
|
|
- #region 3. 混元 AI 远程炼金
|
|
|
|
|
var hunyuanAIInvDatas = new List<InvitationAIInfo>();
|
|
var hunyuanAIInvDatas = new List<InvitationAIInfo>();
|
|
|
if (aiTasks.Any())
|
|
if (aiTasks.Any())
|
|
|
{
|
|
{
|
|
|
- await SendStep(60, $"Hunyuan AI 正在跨境深度检索 {aiTasks.Count} 个国家的邀请单位信息...");
|
|
|
|
|
|
|
+ // 强制冷却(应对 QPS 限制)
|
|
|
|
|
+ // 混元免费版或低阶版本通常有 1s/1次 的频率限制
|
|
|
|
|
+ await Task.Delay(1000);
|
|
|
|
|
|
|
|
- string question = BuildHunyuanPrompt(aiTasks, entryInfo); // 抽离 Prompt 构造
|
|
|
|
|
- string aiRawResponse = string.Empty;
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(60, $"AI 正在跨境检索缺失的 {aiTasks.Sum(x => x.Count)} 条单位资料...");
|
|
|
|
|
+ string searchQuestion = BuildHunyuanPrompt(aiTasks, entryInfo);
|
|
|
|
|
+ string searchRaw = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(searchQuestion);
|
|
|
|
|
|
|
|
- try
|
|
|
|
|
|
|
+ var aiParsed = CleanAndParseJson<List<InvitationAIInfo>>(searchRaw);
|
|
|
|
|
+ if (aiParsed != null)
|
|
|
{
|
|
{
|
|
|
- aiRawResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(question);
|
|
|
|
|
-
|
|
|
|
|
- // 清理 Markdown 代码块包裹
|
|
|
|
|
- string cleanJson = aiRawResponse.Trim();
|
|
|
|
|
- if (cleanJson.StartsWith("```json")) cleanJson = cleanJson[7..^3].Trim();
|
|
|
|
|
- else if (cleanJson.StartsWith("```")) cleanJson = cleanJson[3..^3].Trim();
|
|
|
|
|
-
|
|
|
|
|
- var aiParsed = JsonConvert.DeserializeObject<List<InvitationAIInfo>>(cleanJson);
|
|
|
|
|
- if (aiParsed != null)
|
|
|
|
|
- {
|
|
|
|
|
- hunyuanAIInvDatas = aiParsed.Select(x => {
|
|
|
|
|
- x.Guid = Guid.NewGuid().ToString("N");
|
|
|
|
|
- x.Source = 1; // AI 来源
|
|
|
|
|
- x.Operator = operatorName;
|
|
|
|
|
- x.OperatedAt = DateTime.Now;
|
|
|
|
|
- return x;
|
|
|
|
|
- }).ToList();
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- catch (Exception ex)
|
|
|
|
|
- {
|
|
|
|
|
- _logger.LogError(ex, "Hunyuan AI 接口调用或解析失败");
|
|
|
|
|
- await SendStep(60, "警告:Hunyuan AI 检索部分失败,将仅展示本地数据。");
|
|
|
|
|
|
|
+ hunyuanAIInvDatas = aiParsed.Select(x => {
|
|
|
|
|
+ x.Guid = Guid.NewGuid().ToString("N");
|
|
|
|
|
+ x.Source = 1; // 标识来源:AI 炼金
|
|
|
|
|
+ x.Operator = operatorName;
|
|
|
|
|
+ x.OperatedAt = DateTime.Now;
|
|
|
|
|
+ return x;
|
|
|
|
|
+ }).ToList();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
#endregion
|
|
#endregion
|
|
|
|
|
|
|
|
- await SendStep(90, "执行数据合并与持久化...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(90, "正在同步成果至持久化仓库...");
|
|
|
|
|
|
|
|
- #region 4. 数据合并与入库
|
|
|
|
|
|
|
+ #region 4. 数据融合与更新
|
|
|
var finalResult = localInvDatas.Concat(hunyuanAIInvDatas).ToList();
|
|
var finalResult = localInvDatas.Concat(hunyuanAIInvDatas).ToList();
|
|
|
- invAiInfo.AiCrawledDetails = finalResult;
|
|
|
|
|
|
|
|
|
|
- var updateSuccess = await _sqlSugar.Updateable(invAiInfo).ExecuteCommandAsync() > 0;
|
|
|
|
|
- if (!updateSuccess)
|
|
|
|
|
- {
|
|
|
|
|
- await SendStep(-1, "数据库更新异常。");
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 仅更新指定列,性能更优
|
|
|
|
|
+ invAiInfo.AiCrawledDetails = finalResult.OrderByDescending(x => x.OperatedAt).ToList();
|
|
|
|
|
+ bool isOk = await _sqlSugar.Updateable(invAiInfo)
|
|
|
|
|
+ .UpdateColumns(x => x.AiCrawledDetails)
|
|
|
|
|
+ .ExecuteCommandHasChangeAsync();
|
|
|
|
|
+
|
|
|
|
|
+ if (!isOk) await HttpContext.SendSseStepAsync(-1, $"数据持久化失败。");
|
|
|
#endregion
|
|
#endregion
|
|
|
|
|
|
|
|
- // 5. 最终推送:带上全量结果
|
|
|
|
|
- await SendStep(100, "操作成功!资料已全部就绪。", new
|
|
|
|
|
|
|
+ // 5. 终焉推送
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(100, "操作成功!资料已全部就绪。", new
|
|
|
{
|
|
{
|
|
|
invAiInfo.Id,
|
|
invAiInfo.Id,
|
|
|
- invAiInfo.InvName,
|
|
|
|
|
- AiCrawledDetails = finalResult.OrderByDescending(x => x.OperatedAt).ToList(),
|
|
|
|
|
- Entry = invAiInfo.EntryInfo
|
|
|
|
|
|
|
+ AiCrawledDetails = finalResult.OrderByDescending(x => x.Source).ThenBy(x => x.Region).ToList()
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
catch (Exception ex)
|
|
catch (Exception ex)
|
|
|
{
|
|
{
|
|
|
- _logger.LogError(ex, "SSE 发生不可预知异常");
|
|
|
|
|
- await SendStep(-1, $"SSE Error:{ex.Message}");
|
|
|
|
|
|
|
+ _logger.LogError(ex, "SSE 管道熔断");
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, $"SSE 错误:{ex.InnerException.Message}({ex.Message})");
|
|
|
}
|
|
}
|
|
|
finally
|
|
finally
|
|
|
{
|
|
{
|
|
|
- // 确保最后一次冲刷缓冲区
|
|
|
|
|
- await HttpContext.Response.Body.FlushAsync();
|
|
|
|
|
|
|
+ await HttpContext.FinalizeSseAsync();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 此时方法结束,ASP.NET Core 会自动处理连接关闭
|
|
|
|
|
- _logger.LogInformation("SSE 通道已安全关闭");
|
|
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 炼金辅助:清洗并解析 AI 返回的 JSON 块
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ private static T? CleanAndParseJson<T>(string rawResponse)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (string.IsNullOrWhiteSpace(rawResponse)) return default;
|
|
|
|
|
|
|
|
- // 直接完成响应流
|
|
|
|
|
- await response.CompleteAsync();
|
|
|
|
|
|
|
+ string cleanJson = rawResponse.Trim();
|
|
|
|
|
+ // 自动剥离 Markdown 语法糖
|
|
|
|
|
+ if (cleanJson.Contains("```json"))
|
|
|
|
|
+ cleanJson = cleanJson.Split("```json")[1].Split("```")[0];
|
|
|
|
|
+ else if (cleanJson.Contains("```"))
|
|
|
|
|
+ cleanJson = cleanJson.Split("```")[1].Split("```")[0];
|
|
|
|
|
|
|
|
- }
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ return JsonConvert.DeserializeObject<T>(cleanJson.Trim());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private string BuildHunyuanPrompt(List<CountryAIPormptInfo> tasks, EntryInfo entryInfo)
|
|
|
|
|
- {
|
|
|
|
|
- // 保持你原有的高质量 Prompt 结构
|
|
|
|
|
- return @$"# [SYSTEM_CONTEXT]
|
|
|
|
|
-- Role: Senior Business Consultant (Global Supply Chain & Cross-border Trade Expert)
|
|
|
|
|
-- Framework: S.P.A.R. (Situation-Problem-Action-Result)
|
|
|
|
|
-- Target Architecture: .NET 6 DTO Compatible Interface
|
|
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 构建行业匹配提示词(含预筛选逻辑)
|
|
|
|
|
+ /// <param name="entryInfo">【必填】基础配置信息</param>
|
|
|
|
|
+ /// <param name="localData">【数据源】来自 SqlSugar 查询的本地候选数据集,需包含 Region 和 NameCn 字段</param>
|
|
|
|
|
+ /// <returns>返回经过结构化处理的 Markdown 格式 Prompt 字符串</returns>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public static string BuildIndustryPrompt(
|
|
|
|
|
+ EntryInfo entryInfo,
|
|
|
|
|
+ IEnumerable<InvitationAIInfo> localData)
|
|
|
|
|
+ {
|
|
|
|
|
+ // 业务逻辑预处理:仅保留目标国家数据,大幅节省 Token 成本
|
|
|
|
|
+ var filteredData = localData
|
|
|
|
|
+ .Where(x => entryInfo.TargetCountry.Contains(x.Region))
|
|
|
|
|
+ .Select(x => new { x.Region, x.NameCn }) // 仅提取 AI 需要的字段
|
|
|
|
|
+ .Distinct()
|
|
|
|
|
+ .ToList();
|
|
|
|
|
|
|
|
-# [INPUT_CONFIG]
|
|
|
|
|
-- Tasks: {{{JsonConvert.SerializeObject(tasks)}}}
|
|
|
|
|
-- OriginUnit: {{{entryInfo.OriginUnit}}}
|
|
|
|
|
-- Objective: {{{entryInfo.Objective}}}
|
|
|
|
|
-- OtherConstraints: {{{entryInfo.OtherConstraints}}}
|
|
|
|
|
-# [ROLE_DEFINITION]
|
|
|
|
|
-你是一位精通全球跨境经贸、海外园区政策及 Tasks 中的国家本地准入法规的【顶级商务咨询顾问】。你擅长通过“产业链对等原则(Chain-Parity Principle)”为跨国企业精准匹配具备落地价值的合作伙伴。
|
|
|
|
|
|
|
+ // 序列化数据
|
|
|
|
|
+ var industryStandard = IndustryNode.BuildInitialData().Select(x => x.NameCn).ToList();
|
|
|
|
|
+ string industryStandardStr = string.Join("、", industryStandard);
|
|
|
|
|
+ string countriesStr = string.Join(", ", entryInfo.TargetCountry);
|
|
|
|
|
+ string jsonData = JsonConvert.SerializeObject(filteredData);
|
|
|
|
|
+ string sourceUnit = entryInfo.OriginUnit ?? "未知单位";
|
|
|
|
|
+ string sourceIndustry = string.Join(", ", entryInfo.Industries);
|
|
|
|
|
|
|
|
-# [BUSINESS_LOGIC_CoT]
|
|
|
|
|
-在生成结果前,请严格执行以下思维链拆解:
|
|
|
|
|
-1. **ScopePartitioning**: 遍历 Tasks 中的每个国家。
|
|
|
|
|
-1. **EntityProfiling**: 识别 {{OriginUnit}} 的核心生态位(如:Supply/Demand/Capital/Logistics)。
|
|
|
|
|
-2. **ParityMatching**: 针对每个国家,检索境内业务闭环对等机构。(例如:若 Origin 为分销,则 Target 为源头工厂/种植园)。
|
|
|
|
|
-3. **RegulatoryCheck**: 验证目标机构的合规性(如:GACC 备案、SPS 协议、或当地政府特许经营权)。
|
|
|
|
|
-4. **DataSynthesis**: 必须严格根据每个国家对应的 Count 生成数据条数。针对无法直接获取的动态(如 PostUrl),优先检索其官网 News 频道或 LinkedIn 企业号。
|
|
|
|
|
|
|
+ // 将提示词模板定义为常量,方便维护和查阅
|
|
|
|
|
+ string promptTemplate = @"
|
|
|
|
|
+# Role
|
|
|
|
|
+你是一位精通全球产业结构与大数据清洗的【智能匹配专家】。
|
|
|
|
|
|
|
|
-# [CONSTRAINTS_&_STANDARDS]
|
|
|
|
|
-- **NamingConvention**: 所有 JSON Key 必须严格遵循 **PascalCase**(例如:`UnitNameCn` 而非 `unit_name_cn`)。
|
|
|
|
|
-- **DataIntegrity**:
|
|
|
|
|
- - 总条数必须等于 Tasks 中所有 Count 的总和。
|
|
|
|
|
- - 优先级:Core (核心机构) > Backup (关联替代机构)。
|
|
|
|
|
-- **ValidationRules**:
|
|
|
|
|
- - `Phone`: 必须包含 Tasks 中的国家国际区号(如 +856, +66 等)。
|
|
|
|
|
- - `IntgAdvice`: 必须包含“对等性分析”,解释该机构如何与 {{OriginUnit}} 形成业务闭环(50-100字)。
|
|
|
|
|
- - `Status`: 若字段确实无法获取,统一填充 'N/A',禁止编造。
|
|
|
|
|
-- **Safety**: 确保推荐机构不涉及敏感黑名单或已破产企业。
|
|
|
|
|
|
|
+# Standard Industry Categories (Dynamic)
|
|
|
|
|
+请将目标单位严格归类为以下指定的行业类别:
|
|
|
|
|
+{0}
|
|
|
|
|
+*注意:若无法完全匹配,请归类至语义最接近的一项。*
|
|
|
|
|
|
|
|
-# [INFORMATION_SCHEMA]
|
|
|
|
|
-请将结果填充至以下结构的 JSON 数组中:
|
|
|
|
|
-- Region:国家(必须与 Tasks 中的 Country 完全匹配)
|
|
|
|
|
-- NameCn: 单位名称(中文)
|
|
|
|
|
-- NameEn: 单位名称(英文)
|
|
|
|
|
-- Address: 详细地理位置(含省市区街道)
|
|
|
|
|
-- Scope: 经营范围(需强调其出口配额、生产能力或行业地位)
|
|
|
|
|
-- Contact: 联系人姓名及职务
|
|
|
|
|
-- Phone: 拨打全号(含区号)
|
|
|
|
|
-- Email: 商务联络邮箱
|
|
|
|
|
-- SiteUrl: 官方网站或权威社媒主页
|
|
|
|
|
-- PostUrl: 近一年内的商务动态/新闻链接
|
|
|
|
|
-- RecLevel: 推荐等级(枚举值:Core, Backup)
|
|
|
|
|
-- IntgAdvice: 对接深度建议(基于产业链交合、互补逻辑)
|
|
|
|
|
|
|
+# Situation (Dynamic Input)
|
|
|
|
|
+- **出访单位**:{1}
|
|
|
|
|
+- **其所属行业**:{2}
|
|
|
|
|
+- **目标出访国家**:{3}
|
|
|
|
|
|
|
|
-# [OUTPUT_PROTOCOL]
|
|
|
|
|
-- 仅输出一个标准的 JSON Array 字符串。
|
|
|
|
|
-- 严禁任何 Markdown 说明文字、代码块之外的解释或开场白。
|
|
|
|
|
-- 确保 JSON 语法在 .NET `JsonSerializer.Deserialize` 下可直接解析。
|
|
|
|
|
|
|
+# Task
|
|
|
|
|
+分析【本地待匹配数据集】,执行以下逻辑:
|
|
|
|
|
+1. **实体一致性 (Critical)**:返回结果中的 TargetUnitName 必须与输入数据集中的 NameCn 完全一致,严禁修改。
|
|
|
|
|
+2. **语义归类**:分析业务关键词,并映射至上述标准行业分类。
|
|
|
|
|
+3. **关联度计算**:计算与出访单位的业务契合度 (ConfidenceScore: 0.0-1.0)。
|
|
|
|
|
+
|
|
|
|
|
+# Rules
|
|
|
|
|
+- **输出格式**:仅返回纯 JSON 数组,严禁任何解释性文字。
|
|
|
|
|
+- **命名规范**:JSON 键名严格使用 PascalCase。
|
|
|
|
|
+
|
|
|
|
|
+# Data Source (JSON Array)
|
|
|
|
|
+{4}
|
|
|
|
|
+
|
|
|
|
|
+# Output Format (Required JSON)
|
|
|
|
|
+[
|
|
|
|
|
+ {{
|
|
|
|
|
+ ""SourceUnitName"": ""{1}"",
|
|
|
|
|
+ ""TargetUnitName"": ""必须与输入数据原样一致"",
|
|
|
|
|
+ ""TargetCountry"": ""所在国家"",
|
|
|
|
|
+ ""MatchedIndustry"": ""必须匹配标准行业分类中的原词"",
|
|
|
|
|
+ ""ConfidenceScore"": 0.00,
|
|
|
|
|
+ ""MatchReason"": ""匹配理由""
|
|
|
|
|
+ }}
|
|
|
|
|
+]
|
|
|
|
|
+";
|
|
|
|
|
+ // 填充模板并返回
|
|
|
|
|
+ return string.Format(
|
|
|
|
|
+ promptTemplate,
|
|
|
|
|
+ industryStandardStr, // {0}
|
|
|
|
|
+ sourceUnit, // {1}
|
|
|
|
|
+ sourceIndustry, // {2}
|
|
|
|
|
+ countriesStr, // {3}
|
|
|
|
|
+ jsonData // {4}
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 混元提示词构建(高度定制化,适配商邀资料场景)
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ /// <param name="tasks">包含国家及 Count 数量的任务列表</param>
|
|
|
|
|
+ /// <param name="entryInfo">包含规则基础信息</param>
|
|
|
|
|
+ /// <returns>最终构建的 System Prompt 字符串</returns>
|
|
|
|
|
+ private static string BuildHunyuanPrompt(List<CountryAIPormptInfo> tasks, EntryInfo entryInfo)
|
|
|
|
|
+ {
|
|
|
|
|
+ // 行业信息,用于提示词硬约束
|
|
|
|
|
+ string industryEnum = string.Join("、", IndustryNode.Roots.Select(x => x.NameCn));
|
|
|
|
|
+
|
|
|
|
|
+ //构建其他约束条件字符串
|
|
|
|
|
+ var otherConstraintsStr = new StringBuilder();
|
|
|
|
|
+ if (entryInfo.Industries.Any()) otherConstraintsStr.Append($"- 行业信息: {string.Join("、", entryInfo.Industries)};");
|
|
|
|
|
+ if (entryInfo.ScaleTypes.Any()) otherConstraintsStr.Append($"- 单位规模: {string.Join("、", entryInfo.ScaleTypes)};");
|
|
|
|
|
+ if (entryInfo.IsBackground) otherConstraintsStr.Append($"- 单位是否包含华人背景: 是;");
|
|
|
|
|
+ if (!string.IsNullOrEmpty(entryInfo.OtherConstraints)) otherConstraintsStr.Append($"- 其他规则: {entryInfo.OtherConstraints};");
|
|
|
|
|
+
|
|
|
|
|
+ return @$"
|
|
|
|
|
+# [SYSTEM_ROLE]
|
|
|
|
|
+你是一位精通全球跨境经贸、海外园区政策及准入法规的【顶级商务咨询顾问】。同时,你具备资深的 .NET 6 软件架构思维,擅长生成高精度、符合强类型反序列化要求的结构化 JSON 数据。
|
|
|
|
|
+
|
|
|
|
|
+# [CONTEXT_ANALYSIS]
|
|
|
|
|
+- **发起单位 (OriginUnit)**: {entryInfo.OriginUnit}
|
|
|
|
|
+- **核心目标 (Objective)**: {entryInfo.Objective}
|
|
|
|
|
+- **行业归口枚举**: [{industryEnum}]
|
|
|
|
|
+- **业务约束条件**: {otherConstraintsStr}
|
|
|
|
|
+
|
|
|
|
|
+# [THOUGHT_PROCESS_LOGIC (CoT)]
|
|
|
|
|
+在构建每一条匹配数据前,请严格执行以下逻辑拆解,严禁盲目生成:
|
|
|
|
|
+1. **产业链定位分析 (Parity Logic)**: 深度分析 {entryInfo.OriginUnit} 在产业链中的位置。若其为制造方,则必须匹配具备当地分销能力的“贸易商”或“工程承包商”;若其为下游单位,则匹配上游“工厂”。严禁匹配直接竞争对手。
|
|
|
|
|
+2. **地域合规性校验**: 确保匹配的单位在指定任务国家(Tasks)真实存在,并具备当地市场准入资质。
|
|
|
|
|
+3. **时效性过滤**: 检索目标单位在 **2023-2026** 年间的动态。若无公开新闻,必须确保其官方网站(SiteUrl)具有真实度。
|
|
|
|
|
+4. **数据量校验**: 严格执行 Tasks 中的 `Count` 数量要求,总生成条数必须精准等于所有任务 Count 之和。
|
|
|
|
|
+
|
|
|
|
|
+# [STRICT_DATA_CONTRACT]
|
|
|
|
|
+1. **命名规范 (Mandatory)**: 所有 JSON Key 必须严格遵循 **PascalCase**(大驼峰命名法)。
|
|
|
|
|
+ - 正确示例: `NameCn`, `PostUrl`, `RecLevel`
|
|
|
|
|
+ - 错误示例: `name_cn`, `nameCn`
|
|
|
|
|
+2. **字段取值约束**:
|
|
|
|
|
+ - `Industry`: 必须且只能从枚举值 [{industryEnum}] 中选择其一,不得自行发明。
|
|
|
|
|
+ - `RecLevel`: 仅限使用 `Core` 或 `Backup`。
|
|
|
|
|
+3. **空值与集合处理**:
|
|
|
|
|
+ - 若某字符串字段缺失,填充 `""N/A""`。
|
|
|
|
|
+ - 若 `PostUrl` 数组无动态,必须返回 `[]`,严禁返回 `null`。
|
|
|
|
|
+4. **格式规范**:
|
|
|
|
|
+ - `Phone`: 必须包含国际区号(如 +86, +856)。
|
|
|
|
|
+ - `Date`: 统一使用 `yyyy-MM-dd`。
|
|
|
|
|
+
|
|
|
|
|
+# [INFORMATION_SCHEMA]
|
|
|
|
|
+请将结果填充至以下结构的 JSON Array 中:
|
|
|
|
|
+{{
|
|
|
|
|
+ ""Region"": ""国家名称"",
|
|
|
|
|
+ ""Industry"": ""所属行业(必须是指定的枚举值)"",
|
|
|
|
|
+ ""NameCn"": ""单位名称(中文)"",
|
|
|
|
|
+ ""NameEn"": ""单位名称(英文)"",
|
|
|
|
|
+ ""Address"": ""详细地理位置/总部地址"",
|
|
|
|
|
+ ""Scope"": ""经营范围(侧重描述其在当地的行业地位或配套能力)"",
|
|
|
|
|
+ ""Contact"": ""联系人姓名及职务"",
|
|
|
|
|
+ ""Phone"": ""含区号的联系电话"",
|
|
|
|
|
+ ""Email"": ""商务联络邮箱"",
|
|
|
|
|
+ ""SiteUrl"": ""官方网站或权威主页链接"",
|
|
|
|
|
+ ""PostUrl"": [
|
|
|
|
|
+ {{
|
|
|
|
|
+ ""Date"": ""yyyy-MM-dd"",
|
|
|
|
|
+ ""Description"": ""近三年企业动态简述 (15-30字)"",
|
|
|
|
|
+ ""Url"": ""动态/新闻链接""
|
|
|
|
|
+ }}
|
|
|
|
|
+ ],
|
|
|
|
|
+ ""RecLevel"": ""推荐等级枚举值"",
|
|
|
|
|
+ ""IntgAdvice"": ""对接深度建议(50-100字,基于产业链对等逻辑分析匹配原因)""
|
|
|
|
|
+}}
|
|
|
|
|
+
|
|
|
|
|
+# [OUTPUT_PROTOCOL - CRITICAL]
|
|
|
|
|
+- **MODE**: RAW_TEXT_STREAM
|
|
|
|
|
+- **FORBIDDEN**: 严禁包含任何 Markdown 格式标识符(如 ```json )。
|
|
|
|
|
+- **FORBIDDEN**: 严禁输出任何开场白、中间解释、结尾客套话或补充说明。
|
|
|
|
|
+- **REQUIREMENT**: 只允许输出一个以 `[` 开头,以 `]` 结尾的纯 JSON 字符串,确保其可直接被 `JsonConvert.DeserializeObject<List<T>>` 解析。
|
|
|
|
|
|
|
|
# [EXECUTION]
|
|
# [EXECUTION]
|
|
|
-根据以上配置,开始生成。"; ;
|
|
|
|
|
|
|
+基于以上配置,立即处理以下任务列表:
|
|
|
|
|
+{JsonConvert.SerializeObject(tasks)}";
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
/// <summary>
|
|
/// <summary>
|
|
|
/// 商邀资料AI 设置词条
|
|
/// 商邀资料AI 设置词条
|
|
|
/// </summary>
|
|
/// </summary>
|
|
@@ -2860,8 +2950,10 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
public async Task<IActionResult> InvitationAISetPrompt(InvitationAISetPromptDto dto)
|
|
public async Task<IActionResult> InvitationAISetPrompt(InvitationAISetPromptDto dto)
|
|
|
{
|
|
{
|
|
|
// 基础校验
|
|
// 基础校验
|
|
|
- if (string.IsNullOrWhiteSpace(dto.OriginUnit) || dto.TargetCountry == null || dto.TargetCountry.Count == 0)
|
|
|
|
|
- return Ok(JsonView(false, "请传入有效的单位名称和国家!"));
|
|
|
|
|
|
|
+ if (string.IsNullOrWhiteSpace(dto.OriginUnit) || dto.TargetCountry == null || dto.TargetCountry.Count == 0
|
|
|
|
|
+ || dto.Industries == null || dto.Industries.Count == 0 || dto.ScaleTypes == null || dto.ScaleTypes.Count == 0
|
|
|
|
|
+ )
|
|
|
|
|
+ return Ok(JsonView(false, "请传入有效的单位名称、国家、行业和规模类型!"));
|
|
|
|
|
|
|
|
var groupInfo = await _sqlSugar.Queryable<Grp_DelegationInfo>()
|
|
var groupInfo = await _sqlSugar.Queryable<Grp_DelegationInfo>()
|
|
|
.Where(x => x.IsDel == 0 && x.Id == dto.GroupId)
|
|
.Where(x => x.IsDel == 0 && x.Id == dto.GroupId)
|
|
@@ -2888,6 +2980,9 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
OriginUnit = dto.OriginUnit,
|
|
OriginUnit = dto.OriginUnit,
|
|
|
TargetCountry = dto.TargetCountry,
|
|
TargetCountry = dto.TargetCountry,
|
|
|
Objective = groupInfo?.VisitPurpose ?? "商务考察与合作对接",
|
|
Objective = groupInfo?.VisitPurpose ?? "商务考察与合作对接",
|
|
|
|
|
+ Industries = dto.Industries,
|
|
|
|
|
+ ScaleTypes = dto.ScaleTypes,
|
|
|
|
|
+ IsBackground = dto.IsBackground,
|
|
|
OtherConstraints = dto.OtherConstraints,
|
|
OtherConstraints = dto.OtherConstraints,
|
|
|
Operator = operatorName,
|
|
Operator = operatorName,
|
|
|
OperatedAt = DateTime.Now
|
|
OperatedAt = DateTime.Now
|
|
@@ -3369,24 +3464,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
[HttpPost]
|
|
[HttpPost]
|
|
|
public async Task InvitationAICompleteTextStream([FromBody] InvitationAICompleteTextDto dto)
|
|
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
|
|
|
|
|
- });
|
|
|
|
|
- await response.WriteAsync($"data: {payload}\n\n");
|
|
|
|
|
- await response.Body.FlushAsync();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ HttpContext.InitializeSse();
|
|
|
|
|
|
|
|
try
|
|
try
|
|
|
{
|
|
{
|
|
@@ -3394,10 +3472,10 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.Id == dto.Id).FirstAsync();
|
|
var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.Id == dto.Id).FirstAsync();
|
|
|
if (invAiInfo?.EntryInfo == null)
|
|
if (invAiInfo?.EntryInfo == null)
|
|
|
{
|
|
{
|
|
|
- await SendStep(-1, "请先设置关键字信息!");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "请先设置关键字信息!");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- await SendStep(10, "任务初始化完成,正在计算补齐缺口...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(10, "任务初始化完成,正在计算补齐缺口...");
|
|
|
|
|
|
|
|
// 任务拆解逻辑
|
|
// 任务拆解逻辑
|
|
|
var entryInfo = invAiInfo.EntryInfo;
|
|
var entryInfo = invAiInfo.EntryInfo;
|
|
@@ -3413,7 +3491,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
|
|
|
|
|
if (!aiTasks.Any())
|
|
if (!aiTasks.Any())
|
|
|
{
|
|
{
|
|
|
- await SendStep(100, "数据已满额,无需续写", invAiInfo.AiCrawledDetails);
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(100, "数据已满额,无需续写", invAiInfo.AiCrawledDetails);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -3425,14 +3503,12 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
// 构建 Question
|
|
// 构建 Question
|
|
|
string question = BuildHunyuanPrompt(aiTasks, entryInfo);
|
|
string question = BuildHunyuanPrompt(aiTasks, entryInfo);
|
|
|
|
|
|
|
|
- await SendStep(30, "Hunyuan AI 正在深度检索跨境商邀数据,请稍候...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(30, "AI 正在深度检索跨境商邀数据,请稍候...");
|
|
|
|
|
|
|
|
// 调用 AI (Progress: 30% - 80%)
|
|
// 调用 AI (Progress: 30% - 80%)
|
|
|
- // 注意:这里如果能换成流式接口(ChatCompletionsStream)会更好,
|
|
|
|
|
- // 如果依然用非流式,这里会有一段较长的等待。
|
|
|
|
|
string aiRawResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(question);
|
|
string aiRawResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(question);
|
|
|
|
|
|
|
|
- await SendStep(85, "数据已捕获,正在进行格式校验与去重...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(85, "数据已捕获,正在进行格式校验与去重...");
|
|
|
|
|
|
|
|
// 5. 解析与清洗数据
|
|
// 5. 解析与清洗数据
|
|
|
var hunyuanAIInvDatas = ProcessAIResponse(aiRawResponse);
|
|
var hunyuanAIInvDatas = ProcessAIResponse(aiRawResponse);
|
|
@@ -3454,22 +3530,21 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
|
|
|
|
|
if (update > 0)
|
|
if (update > 0)
|
|
|
{
|
|
{
|
|
|
- await SendStep(100, $"Hunyuan AI 续写成功!新增 {hunyuanAIInvDatas.Count} 条数据", invAiInfo.AiCrawledDetails);
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(100, $"AI 续写成功!新增 {hunyuanAIInvDatas.Count} 条数据", invAiInfo.AiCrawledDetails);
|
|
|
}
|
|
}
|
|
|
else
|
|
else
|
|
|
{
|
|
{
|
|
|
- await SendStep(-1, "数据库更新失败");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "数据库更新失败");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
catch (Exception ex)
|
|
catch (Exception ex)
|
|
|
{
|
|
{
|
|
|
_logger.LogError(ex, "SSE 续写异常");
|
|
_logger.LogError(ex, "SSE 续写异常");
|
|
|
- await SendStep(-1, $"炼金炸炉:{ex.Message}");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, $"炼金炸炉:{ex.Message}");
|
|
|
}
|
|
}
|
|
|
finally
|
|
finally
|
|
|
{
|
|
{
|
|
|
- await response.Body.FlushAsync();
|
|
|
|
|
- await response.CompleteAsync(); // 彻底关闭连接
|
|
|
|
|
|
|
+ await HttpContext.FinalizeSseAsync();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -3574,7 +3649,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
|
|
|
|
|
AddSingleLinkRow(builder, fontName, labelW, usableWidth - labelW, "官方网站", item.SiteUrl);
|
|
AddSingleLinkRow(builder, fontName, labelW, usableWidth - labelW, "官方网站", item.SiteUrl);
|
|
|
|
|
|
|
|
- AddSingleLinkRow(builder, fontName, labelW, usableWidth - labelW, "最新动态", item.PostUrl);
|
|
|
|
|
|
|
+ AddSingleLinkRow(builder, fontName, labelW, usableWidth - labelW, "最近三年动态", string.Join(", ", item.PostUrl.Select(p => p.Url)));
|
|
|
|
|
|
|
|
AddFullWidthRow(builder, fontName, labelW, usableWidth - labelW, "推荐等级", item.RecLevel);
|
|
AddFullWidthRow(builder, fontName, labelW, usableWidth - labelW, "推荐等级", item.RecLevel);
|
|
|
|
|
|
|
@@ -3946,42 +4021,24 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
[HttpPost]
|
|
[HttpPost]
|
|
|
public async Task InvitationAIGenerateEmailStream([FromBody] InvitationAIGenerateEmailDto dto)
|
|
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();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
|
|
+ HttpContext.InitializeSse();
|
|
|
try
|
|
try
|
|
|
{
|
|
{
|
|
|
// 1. 基础校验与资源加载 (Progress: 5%)
|
|
// 1. 基础校验与资源加载 (Progress: 5%)
|
|
|
if (dto.Id < 1 || dto.Guids == null || !dto.Guids.Any())
|
|
if (dto.Id < 1 || dto.Guids == null || !dto.Guids.Any())
|
|
|
{
|
|
{
|
|
|
- await SendStep(-1, "请求参数不完整,请选择单位");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "请求参数不完整,请选择单位");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.Id == dto.Id).FirstAsync();
|
|
var invAiInfo = await _sqlSugar.Queryable<Res_InvitationAI>().Where(x => x.Id == dto.Id).FirstAsync();
|
|
|
if (invAiInfo?.AiCrawledDetails == null)
|
|
if (invAiInfo?.AiCrawledDetails == null)
|
|
|
{
|
|
{
|
|
|
- await SendStep(-1, "基础商邀资料不存在");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "基础商邀资料不存在");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- await SendStep(10, "Hunyuan AI 正在分析考察团组背景与访问意图...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(10, "AI 正在分析考察团组背景与访问意图...");
|
|
|
|
|
|
|
|
// 2. 准备 AI 上下文数据 (Progress: 15%)
|
|
// 2. 准备 AI 上下文数据 (Progress: 15%)
|
|
|
var clientInfoSources = invAiInfo.AiCrawledDetails.Where(x => dto.Guids.Contains(x.Guid)).ToList();
|
|
var clientInfoSources = invAiInfo.AiCrawledDetails.Where(x => dto.Guids.Contains(x.Guid)).ToList();
|
|
@@ -4001,7 +4058,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
.Where(x => x.Id == dto.CurrUserId).Select(x => x.CnName).FirstAsync() ?? "-";
|
|
.Where(x => x.Id == dto.CurrUserId).Select(x => x.CnName).FirstAsync() ?? "-";
|
|
|
|
|
|
|
|
// 3. 构建 Prompt 并调用 AI (Progress: 25%)
|
|
// 3. 构建 Prompt 并调用 AI (Progress: 25%)
|
|
|
- await SendStep(30, $"Hunyuan AI 正在为 {clientInfosForAI.Count} 家单位撰写定制化正式邮件...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(30, $"AI 正在为 {clientInfosForAI.Count} 家单位撰写定制化正式邮件...");
|
|
|
|
|
|
|
|
// 此处沿用你原有的庞大 Prompt 逻辑
|
|
// 此处沿用你原有的庞大 Prompt 逻辑
|
|
|
string pormpt = $@"
|
|
string pormpt = $@"
|
|
@@ -4074,7 +4131,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
// 调用 AI (此处为阻塞式等待 AI 结果,若混元支持 Stream 可进一步拆解)
|
|
// 调用 AI (此处为阻塞式等待 AI 结果,若混元支持 Stream 可进一步拆解)
|
|
|
string aiResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(pormpt);
|
|
string aiResponse = await _hunyuanService.ChatCompletionsHunyuan_t1_latestAsync(pormpt);
|
|
|
|
|
|
|
|
- await SendStep(80, "邮件初稿已生成,正在进行 HTML 格式校验与转义处理...");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(80, "邮件初稿已生成,正在进行 HTML 格式校验与转义处理...");
|
|
|
|
|
|
|
|
// 4. 解析结果 (Progress: 85%)
|
|
// 4. 解析结果 (Progress: 85%)
|
|
|
var hunyuanAIEmailDatas = new List<AICreateEmailInfo>();
|
|
var hunyuanAIEmailDatas = new List<AICreateEmailInfo>();
|
|
@@ -4101,7 +4158,7 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
}
|
|
}
|
|
|
if (hunyuanAIEmailDatas == null || !hunyuanAIEmailDatas.Any())
|
|
if (hunyuanAIEmailDatas == null || !hunyuanAIEmailDatas.Any())
|
|
|
{
|
|
{
|
|
|
- await SendStep(-1, "AI 格式解析失败,请尝试重新生成");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "AI 格式解析失败,请尝试重新生成");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -4124,21 +4181,21 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
var update = await _sqlSugar.Updateable(invAiInfo).UpdateColumns(x => x.AiCrawledDetails).ExecuteCommandAsync();
|
|
var update = await _sqlSugar.Updateable(invAiInfo).UpdateColumns(x => x.AiCrawledDetails).ExecuteCommandAsync();
|
|
|
if (update > 0)
|
|
if (update > 0)
|
|
|
{
|
|
{
|
|
|
- await SendStep(100, "邮件全部生成完毕并已存入团组资料库", invAiInfo.AiCrawledDetails);
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(100, "邮件全部生成完毕并已存入团组资料库", invAiInfo.AiCrawledDetails);
|
|
|
}
|
|
}
|
|
|
else
|
|
else
|
|
|
{
|
|
{
|
|
|
- await SendStep(-1, "数据库写入失败,请联系管理员");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "数据库写入失败,请联系管理员");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
catch (Exception ex)
|
|
catch (Exception ex)
|
|
|
{
|
|
{
|
|
|
_logger.LogError(ex, "邮件生成异常");
|
|
_logger.LogError(ex, "邮件生成异常");
|
|
|
- await SendStep(-1, $"生成失败:{ex.Message}");
|
|
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, $"生成失败:{ex.Message}");
|
|
|
}
|
|
}
|
|
|
finally
|
|
finally
|
|
|
{
|
|
{
|
|
|
- await response.CompleteAsync();
|
|
|
|
|
|
|
+ await HttpContext.FinalizeSseAsync();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -4283,6 +4340,162 @@ Inner Join Sys_Department as d With(Nolock) On u.DepId=d.Id Where m.Id={0} ", _m
|
|
|
return Ok(JsonView(true, msgSb.ToString()));
|
|
return Ok(JsonView(true, msgSb.ToString()));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 商邀资料AI 发送邮件(SSE 流式推送)
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [HttpPost]
|
|
|
|
|
+ public async Task InvitationAISeedEmailStream([FromBody] InvitationAISeedEmailDto dto)
|
|
|
|
|
+ {
|
|
|
|
|
+ // 1. 初始化 SSE
|
|
|
|
|
+ HttpContext.InitializeSse();
|
|
|
|
|
+
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(5, "正在准备发送队列...");
|
|
|
|
|
+
|
|
|
|
|
+ #region 1. 参数与权限前置校验
|
|
|
|
|
+ if (dto.Id < 1 || dto.CurrUserId < 1 || dto.Guids == null || !dto.Guids.Any())
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "参数验证失败,请检查选择的单位及用户状态。");
|
|
|
|
|
+ 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();
|
|
|
|
|
+
|
|
|
|
|
+ await Task.WhenAll(invAiTask, userTask);
|
|
|
|
|
+
|
|
|
|
|
+ var invAiInfo = await invAiTask;
|
|
|
|
|
+ var userInfo = await userTask;
|
|
|
|
|
+
|
|
|
|
|
+ if (invAiInfo?.AiCrawledDetails == null)
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "未找到有效的邀请方数据。");
|
|
|
|
|
+ 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();
|
|
|
|
|
+
|
|
|
|
|
+ if (!seedInvInfos.Any())
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "所选单位信息在原始库中不存在。");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ #endregion
|
|
|
|
|
+
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(15, $"准备就绪,共计 {seedInvInfos.Count} 封邮件待发送...");
|
|
|
|
|
+
|
|
|
|
|
+ #region 2. 批量发送逻辑 (流式反馈)
|
|
|
|
|
+ int total = seedInvInfos.Count;
|
|
|
|
|
+ int current = 0;
|
|
|
|
|
+ var successCount = 0;
|
|
|
|
|
+ var failCount = 0;
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var item in seedInvInfos)
|
|
|
|
|
+ {
|
|
|
|
|
+ current++;
|
|
|
|
|
+ // 计算进度:从 20% 到 90%
|
|
|
|
|
+ int progress = 20 + (int)((double)current / total * 70);
|
|
|
|
|
+
|
|
|
|
|
+ if (string.IsNullOrEmpty(item.EmailInfo?.EmailTitle) || string.IsNullOrEmpty(item.EmailInfo?.EmailContent))
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(progress, $"跳过:{item.NameCn} (邮件标题或内容缺失)");
|
|
|
|
|
+ failCount++;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ var req = new EmailRequestDto()
|
|
|
|
|
+ {
|
|
|
|
|
+ ToEmails = new List<string> { item.Email },
|
|
|
|
|
+ Subject = item.EmailInfo.EmailTitle,
|
|
|
|
|
+ Body = item.EmailInfo.EmailContent,
|
|
|
|
|
+ Files = Array.Empty<IFormFile>()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 调用企微 API
|
|
|
|
|
+ var response = await _qiYeWeChatApiService.EmailSendAsync(req);
|
|
|
|
|
+
|
|
|
|
|
+ if (response.errcode == 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ successCount++;
|
|
|
|
|
+ // 更新本地状态
|
|
|
|
|
+ item.EmailInfo.Status = 4; // 发送成功状态
|
|
|
|
|
+ item.EmailInfo.Operator = userInfo.CnName;
|
|
|
|
|
+ item.EmailInfo.OperatedAt = DateTime.Now;
|
|
|
|
|
+
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(progress, $"成功:已向 {item.NameCn}({item.Email}) 发送邮件");
|
|
|
|
|
+ }
|
|
|
|
|
+ else
|
|
|
|
|
+ {
|
|
|
|
|
+ failCount++;
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(progress, $"失败:{item.NameCn} 发送失败 ({response.errmsg})");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ failCount++;
|
|
|
|
|
+ _logger.LogError(ex, $"企微邮件推送异常:{item.NameCn}");
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(progress, $"异常:{item.NameCn} 连接超时");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 频率控制:防止企微 API 触发 QPS 限制 (炼金建议:根据实际情况调整)
|
|
|
|
|
+ await Task.Delay(300);
|
|
|
|
|
+ }
|
|
|
|
|
+ #endregion
|
|
|
|
|
+
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(95, "正在归档发送记录...");
|
|
|
|
|
+
|
|
|
|
|
+ #region 3. 数据同步与收尾
|
|
|
|
|
+ // 更新内存中的全量数据:排除旧的,加入已更新状态的
|
|
|
|
|
+ invAiInfo.AiCrawledDetails = invAiInfo.AiCrawledDetails
|
|
|
|
|
+ .Where(x => !guidSet.Contains(x.Guid))
|
|
|
|
|
+ .Concat(seedInvInfos)
|
|
|
|
|
+ .OrderByDescending(x => x.EmailInfo.OperatedAt)
|
|
|
|
|
+ .ToList();
|
|
|
|
|
+
|
|
|
|
|
+ // 精准更新 JSON 列
|
|
|
|
|
+ var updateOk = await _sqlSugar.Updateable(invAiInfo)
|
|
|
|
|
+ .UpdateColumns(x => x.AiCrawledDetails)
|
|
|
|
|
+ .ExecuteCommandHasChangeAsync();
|
|
|
|
|
+
|
|
|
|
|
+ if (!updateOk)
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, "记录保存失败,请检查数据库连接。");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(100, $"任务完成!成功: {successCount}, 失败: {failCount}", new
|
|
|
|
|
+ {
|
|
|
|
|
+ Id = invAiInfo.Id,
|
|
|
|
|
+ SuccessCount = successCount,
|
|
|
|
|
+ FailCount = failCount
|
|
|
|
|
+ });
|
|
|
|
|
+ #endregion
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ _logger.LogError(ex, "邮件发送流发生崩溃");
|
|
|
|
|
+ await HttpContext.SendSseStepAsync(-1, $"系统错误:{ex.Message}");
|
|
|
|
|
+ }
|
|
|
|
|
+ finally
|
|
|
|
|
+ {
|
|
|
|
|
+ await HttpContext.FinalizeSseAsync();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#endregion
|
|
#endregion
|
|
|
|
|
|
|
|
#region 公务出访
|
|
#region 公务出访
|