|
|
@@ -33,6 +33,7 @@ import org.slf4j.Logger;
|
|
|
import org.springframework.beans.BeanUtils;
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
import org.springframework.context.annotation.Lazy;
|
|
|
+import org.springframework.scheduling.annotation.Async;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
import org.springframework.util.CollectionUtils;
|
|
|
@@ -538,42 +539,71 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
|
|
|
return rows;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 生成邮件正文(调 AI 重写内容)并落库。
|
|
|
+ * <p>
|
|
|
+ * 一次调用 = 一次 AI 请求,耗时通常是秒级,一个 SOP 步骤下可能有几十上百封邮件,
|
|
|
+ * 所以这里必须是异步的:@Async 放在<strong>实现类方法</strong>上(放在接口方法上时,
|
|
|
+ * 一旦 bean 走 CGLIB 代理或与其它 AOP 代理叠加,匹配行为会随代理方式变化,不如放实现类稳妥)。
|
|
|
+ * <p>
|
|
|
+ * 异步线程的异常不会回到请求线程,因此内部自行兜住并记 error 日志,
|
|
|
+ * 避免失败后邮件一直停在旧内容上却没有任何痕迹。
|
|
|
+ */
|
|
|
@Override
|
|
|
+ @Async("tradeEmailExecutor")
|
|
|
public void updateEmailContent(MarketEmailsEntity marketEmailsEntity) {
|
|
|
- //通过客户id获取客户分析结果
|
|
|
- CustomerAnalysisResultEntity customerAnalysisResult = new CustomerAnalysisResultEntity();
|
|
|
- LambdaQueryWrapper<CustomerAnalysisResultEntity> queryWrapper = new LambdaQueryWrapper<>();
|
|
|
- queryWrapper.eq(CustomerAnalysisResultEntity::getCustomerId, marketEmailsEntity.getCustomerId());
|
|
|
- queryWrapper.last("limit 1");
|
|
|
- customerAnalysisResult = customerAnalysisResultService.getOne(queryWrapper);
|
|
|
- String reasonSummary = "";
|
|
|
- if (customerAnalysisResult != null){
|
|
|
- reasonSummary = customerAnalysisResult.getReasonSummary();
|
|
|
- }
|
|
|
- Map sendMap = new HashMap();
|
|
|
- sendMap.put("email", marketEmailsEntity.getContent());
|
|
|
- sendMap.put("reason_summary", reasonSummary);
|
|
|
-
|
|
|
- ChatDTO chatDTO = new ChatDTO();
|
|
|
- chatDTO.setInputs(sendMap);
|
|
|
-// chatDTO.setQuery("send_time");
|
|
|
- chatDTO.setResponseMode("blocking");
|
|
|
- chatDTO.setAppId("app-CVPtLUPO0zd2SeGPvZv3Q4Ev");
|
|
|
- chatDTO.setUser("1");
|
|
|
-
|
|
|
- // 调取 AI 获取新的content
|
|
|
- Result<Object> r = workflowsService.AiWorkflows(chatDTO);
|
|
|
- if (r == null) {
|
|
|
- log.warn("无内容生成");
|
|
|
- return;
|
|
|
- }
|
|
|
- // 从结构化结果里取出 AI 真正输出的 text 字段(多个结束节点拼接)。
|
|
|
- // 不再做 inner.toString() 兜底——整串 Result 里有 files=[]/inputs=[] 等空数组
|
|
|
- // 会被误当成 AI 输出的 JSON 数组(如 [models]),污染解析。
|
|
|
- String text = extractCleanText(r.getResult());
|
|
|
- marketEmailsEntity.setContent(text);
|
|
|
+ log.info("[updateEmailContent] 提交异步生成,emailId={},customerId={},线程={}",
|
|
|
+ marketEmailsEntity.getId(), marketEmailsEntity.getCustomerId(), Thread.currentThread().getName());
|
|
|
+ try {
|
|
|
+ //通过客户id获取客户分析结果
|
|
|
+ CustomerAnalysisResultEntity customerAnalysisResult = new CustomerAnalysisResultEntity();
|
|
|
+ LambdaQueryWrapper<CustomerAnalysisResultEntity> queryWrapper = new LambdaQueryWrapper<>();
|
|
|
+ queryWrapper.eq(CustomerAnalysisResultEntity::getCustomerId, marketEmailsEntity.getCustomerId());
|
|
|
+ queryWrapper.last("limit 1");
|
|
|
+ customerAnalysisResult = customerAnalysisResultService.getOne(queryWrapper);
|
|
|
+ String reasonSummary = "";
|
|
|
+ if (customerAnalysisResult != null) {
|
|
|
+ reasonSummary = customerAnalysisResult.getReasonSummary();
|
|
|
+ }
|
|
|
+ Map sendMap = new HashMap();
|
|
|
+ sendMap.put("email", marketEmailsEntity.getContent());
|
|
|
+ sendMap.put("reason_summary", reasonSummary);
|
|
|
+
|
|
|
+ ChatDTO chatDTO = new ChatDTO();
|
|
|
+ chatDTO.setInputs(sendMap);
|
|
|
+ chatDTO.setResponseMode("blocking");
|
|
|
+ chatDTO.setAppId("app-CVPtLUPO0zd2SeGPvZv3Q4Ev");
|
|
|
+ chatDTO.setUser("1");
|
|
|
|
|
|
- this.updateById(marketEmailsEntity);
|
|
|
+ // 调取 AI 获取新的content
|
|
|
+ Result<Object> r = workflowsService.AiWorkflows(chatDTO);
|
|
|
+ if (r == null) {
|
|
|
+ log.warn("无内容生成,emailId={}", marketEmailsEntity.getId());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 从结构化结果里取出 AI 真正输出的 text 字段(多个结束节点拼接)。
|
|
|
+ // 不再做 inner.toString() 兜底——整串 Result 里有 files=[]/inputs=[] 等空数组
|
|
|
+ // 会被误当成 AI 输出的 JSON 数组(如 [models]),污染解析。
|
|
|
+ String text = extractCleanText(r.getResult());
|
|
|
+ // 去掉 <think>...</think>(含标签本身)。思考块是多行的,必须用 stripThinkBlocks 里的 DOTALL 正则,
|
|
|
+ // 直接写 replaceAll("<think>.*?</think>", "") 会因为 . 不匹配换行而整段删不掉。
|
|
|
+ text = stripThinkBlocks(text);
|
|
|
+ if (text == null) {
|
|
|
+ log.warn("AI 返回内容为空,emailId={}", marketEmailsEntity.getId());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 删掉思考块后正文开头/结尾常留下一串空行:连续 3 个以上换行压成 2 个(保留段落间隔),再去首尾空白
|
|
|
+ text = text.replaceAll("\\n{3,}", "\n\n").trim();
|
|
|
+ marketEmailsEntity.setContent(text);
|
|
|
+
|
|
|
+ this.updateById(marketEmailsEntity);
|
|
|
+ log.info("[updateEmailContent] 生成完成并已落库,emailId={},线程={}",
|
|
|
+ marketEmailsEntity.getId(), Thread.currentThread().getName());
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 异步线程:异常不会回到请求线程,这里必须自己兜住,否则失败将毫无痕迹
|
|
|
+ log.error("[updateEmailContent] 生成邮件内容失败,emailId={},customerId={}",
|
|
|
+ marketEmailsEntity.getId(), marketEmailsEntity.getCustomerId(), e);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -1055,17 +1085,20 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
|
|
|
}
|
|
|
|
|
|
@Override
|
|
|
- public Result<Map<Long, Object>> getAccountStats(List<Long> outboxIds) {
|
|
|
- Map<Long, Object> result = new LinkedHashMap<>();
|
|
|
+ public List<MarketEmailsAccountStatsVO> getAccountStats(List<Long> outboxIds) {
|
|
|
+ List<MarketEmailsAccountStatsVO> result = new ArrayList<>();
|
|
|
if (CollectionUtils.isEmpty(outboxIds)) {
|
|
|
- return Result.result(result);
|
|
|
+ return result;
|
|
|
}
|
|
|
List<Long> ids = outboxIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
|
|
if (ids.isEmpty()) {
|
|
|
- return Result.result(result);
|
|
|
+ return result;
|
|
|
}
|
|
|
+ Map<Long, MarketEmailsAccountStatsVO> byId = new LinkedHashMap<>();
|
|
|
for (Long id : ids) {
|
|
|
- result.put(id, emptyAccountStats(id));
|
|
|
+ MarketEmailsAccountStatsVO vo = emptyAccountStats(id);
|
|
|
+ byId.put(id, vo);
|
|
|
+ result.add(vo);
|
|
|
}
|
|
|
|
|
|
QueryWrapper<MarketEmailsEntity> qw = new QueryWrapper<>();
|
|
|
@@ -1080,27 +1113,25 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
|
|
|
qw.groupBy("outbox_id");
|
|
|
List<Map<String, Object>> rows = this.listMaps(qw);
|
|
|
if (CollectionUtils.isEmpty(rows)) {
|
|
|
- return Result.result(result);
|
|
|
+ return result;
|
|
|
}
|
|
|
for (Map<String, Object> row : rows) {
|
|
|
if (row == null) {
|
|
|
continue;
|
|
|
}
|
|
|
Long id = toLongOrNull(row.get("outbox_id"));
|
|
|
- if (id == null || !result.containsKey(id)) {
|
|
|
+ MarketEmailsAccountStatsVO vo = id == null ? null : byId.get(id);
|
|
|
+ if (vo == null) {
|
|
|
continue;
|
|
|
}
|
|
|
- MarketEmailsAccountStatsVO vo = new MarketEmailsAccountStatsVO();
|
|
|
int sendCount = toInteger(row.get("send_count"));
|
|
|
int replyCount = toInteger(row.get("reply_count"));
|
|
|
- vo.setOutboxId(id);
|
|
|
vo.setSendCount(sendCount);
|
|
|
vo.setReplyCount(replyCount);
|
|
|
vo.setReplyRate(toReplyRate(sendCount, replyCount));
|
|
|
vo.setLastSendTime(toDate(row.get("last_send_time")));
|
|
|
- result.put(id, vo);
|
|
|
}
|
|
|
- return Result.result(result);
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
private MarketEmailsAccountStatsVO emptyAccountStats(Long outboxId) {
|
|
|
@@ -1796,6 +1827,35 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
|
|
|
return t == null ? null : t.toString();
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 剥掉 AI 输出里的思考块。
|
|
|
+ * <p>
|
|
|
+ * 规则:<strong>凡是落在 {@code <think>} 与 {@code </think>} 之间的内容,连同这两个标签本身一起删掉</strong>;
|
|
|
+ * 标签之外的正文原样保留(多个思考块夹着正文时,只删块、保留正文)。
|
|
|
+ * <p>
|
|
|
+ * 两个坑:
|
|
|
+ * <ol>
|
|
|
+ * <li><strong>必须开 DOTALL</strong>。模型的思考过程几乎都是多行的,而 Java 正则里 {@code .} 默认
|
|
|
+ * 不匹配 {@code \n}。写成 {@code "<think>.*?</think>"} 时多行块整个匹配不上,
|
|
|
+ * 结果就是一整段思考过程原样留在邮件正文里 —— 本次的 bug 就在这里。</li>
|
|
|
+ * <li>兼容"只有 {@code <think>} 没有 {@code </think>}"的截断情况(思考过程超长被砍断很常见),
|
|
|
+ * 此时从开标签一直删到文本结尾,避免思考片段混进正文。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * @param text 原始文本,允许为 null
|
|
|
+ * @return 去掉思考块后的文本;null 原样返回
|
|
|
+ */
|
|
|
+ private String stripThinkBlocks(String text) {
|
|
|
+ if (text == null || text.isEmpty()) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+ // (?s) 让 . 匹配换行;(?i) 兼容 <Think>;[^>]* 兼容带属性的开标签(如 <think lang="zh">)
|
|
|
+ // (?:...</think>|$) 兜底未闭合:有闭合标签就截到闭合处,没有就截到结尾
|
|
|
+ String cleaned = text.replaceAll("(?si)<think\\b[^>]*>[\\s\\S]*?(?:</think>|$)", "");
|
|
|
+ // 再兜一层:清掉可能残留的孤立标签(比如正文里孤零零一个 </think>)
|
|
|
+ return cleaned.replaceAll("(?i)</?think\\b[^>]*>", "");
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 从 AI 文本中解析排期阶段列表。
|
|
|
* 兼容:思考块包裹、json 代码块、裸数组、全角标点/括号,以及模型把输出数组重复拼接的情况。
|
|
|
@@ -1919,7 +1979,7 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
|
|
|
*/
|
|
|
private List<String> extractJsonCandidates(String text) {
|
|
|
List<String> result = new ArrayList<>();
|
|
|
- String cleaned = text.replaceAll("(?s)<think>[\\s\\S]*?</think>", "");
|
|
|
+ String cleaned = stripThinkBlocks(text);
|
|
|
int from = 0;
|
|
|
while (true) {
|
|
|
int start = cleaned.indexOf('[', from);
|