Переглянути джерело

Merge remote-tracking branch 'origin/master'

zhujing 7 годин тому
батько
коміт
db8dfff1a7
22 змінених файлів з 983 додано та 42 видалено
  1. 0 1
      java/storlead-mail/storlead-mail-api/src/main/java/com/storlead/mail/controller/mkt/MktEmailAccountApiController.java
  2. 29 2
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/client/MarketEmailsClient.java
  3. 19 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketEmailsController.java
  4. 156 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MailMessageDTO.java
  5. 10 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/SopDetailEntity.java
  6. 27 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketEmailsService.java
  7. 18 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/SopDetailService.java
  8. 77 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerTradeServiceImpl.java
  9. 273 6
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java
  10. 49 15
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignSopDetailServiceImpl.java
  11. 166 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/SopDetailServiceImpl.java
  12. 33 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/task/SopDetailCountTask.java
  13. 7 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/CustomerVO.java
  14. 40 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsAccountStatsVO.java
  15. 29 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsTodayStatsVO.java
  16. 23 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsTrendVO.java
  17. 1 1
      ui/smarttrade-platform/src/assets/styles/marketing.css
  18. 1 1
      ui/smarttrade-platform/src/assets/styles/settings.css
  19. 14 5
      ui/smarttrade-platform/src/components/addMarketing.vue
  20. 8 8
      ui/smarttrade-platform/src/components/email/email.vue
  21. 1 1
      ui/smarttrade-platform/src/components/marketing/customerDetail.vue
  22. 2 2
      ui/smarttrade-platform/src/components/marketing/marketingDetail.vue

+ 0 - 1
java/storlead-mail/storlead-mail-api/src/main/java/com/storlead/mail/controller/mkt/MktEmailAccountApiController.java

@@ -83,7 +83,6 @@ public class MktEmailAccountApiController {
     @PostMapping("/personal_mail_account")
     @ApiOperation("营销邮箱账户(含未读数)")
     public Result<?> personalMailAccount() {
-
         return mktAccountRemoteClient.personalMailAccount();
     }
 

+ 29 - 2
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/client/MarketEmailsClient.java

@@ -1,11 +1,13 @@
 package com.storlead.trade.client;
 
+import com.alibaba.fastjson.JSON;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.core.tenant.TenantConstants;
 import com.storlead.mail.webclient.config.MailRemoteProperties;
 import com.storlead.mail.webclient.constant.MailRemoteApiConstants;
 import com.storlead.mail.webclient.support.MailRemoteHttpSupport;
+import com.storlead.trade.dto.MailMessageDTO;
 import lombok.RequiredArgsConstructor;
 import org.springframework.util.StringUtils;
 import org.springframework.web.reactive.function.client.WebClient;
@@ -24,12 +26,37 @@ public class MarketEmailsClient {
 
     private final MailRemoteHttpSupport http;
 
-    public Result<?> emailList(List<Long> ids) {
+    public Result<List<MailMessageDTO>> emailList(List<Long> ids) {
         // B 服务是 @RequestParam("ids") List<Long>,必须按 form 重复 key:ids=2&ids=3。
         // 不能 postJson 直接传 List:body 会变成 [2,3],B 端当成字符串 "[2, 3]" 再按逗号拆,
         // 得到 "[2" 和 " 3]",转不成 Long,表现为 list size 正确但元素全是 null。
         // 也不能 postForm(path, ids):toForm 会对 List 做 convertValue(..., Map) 抛异常。
-        return http.postForm(MailRemoteApiConstants.MKT_LIST_BY_IDS, http.formOf("ids", ids));
+        Result<?> raw = http.postForm(MailRemoteApiConstants.MKT_LIST_BY_IDS, http.formOf("ids", ids));
+        return toMailListResult(raw);
+    }
+
+    /**
+     * 把 Result.result(WebClient 用 Jackson 反序列化出来的 List&lt;LinkedHashMap&gt;)
+     * 转成强类型 List&lt;MailMessageDTO&gt;,其余 code / message / success / timestamp 原样保留。
+     * <p>
+     * 转换走 fastjson:项目主转换器就是 FastJson;它默认忽略未知字段、
+     * 能自动解析 B 服务返回的 "yyyy-MM-dd HH:mm:ss" 日期字符串。
+     */
+    private Result<List<MailMessageDTO>> toMailListResult(Result<?> raw) {
+        if (raw == null) {
+            return null;
+        }
+        Result<List<MailMessageDTO>> typed = new Result<>();
+        typed.setSuccess(raw.isSuccess());
+        typed.setMessage(raw.getMessage());
+        typed.setCode(raw.getCode());
+        typed.setTimestamp(raw.getTimestamp());
+        Object data = raw.getResult();
+        if (data == null) {
+            return typed;
+        }
+        typed.setResult(JSON.parseArray(JSON.toJSONString(data), MailMessageDTO.class));
+        return typed;
     }
 
     /**

+ 19 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketEmailsController.java

@@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
+import java.util.Map;
 
 import javax.annotation.Resource;
 
@@ -144,6 +145,12 @@ public class MarketEmailsController {
         return marketEmailsService.getManagementStats(id);
     }
 
+    @GetMapping("/today-stats")
+    @ApiOperation("营销: 今日邮件统计(今日发送=expect_send_time 今天且 status=1;今日打开=first_open_time 今天;今日回复=first_reply_time 今天;今日拒收=expect_send_time 今天且 has_refuse=1)")
+    public Result<Object> todayStats() {
+        return marketEmailsService.getTodayStats();
+    }
+
     @PostMapping("/management-page")
     @ApiOperation("营销: 邮件管理弹窗-按状态分页查询(按 marketingCampaignId + status)")
     public Result<Object> managementPage(@RequestBody MarketEmailsDTO marketEmailsDTO) {
@@ -152,6 +159,18 @@ public class MarketEmailsController {
 
 
 
+    @GetMapping("/send-trend-7d")
+    @ApiOperation("营销: 最近 7 天发送趋势(按天统计发送成功邮件数,无数据补 0,按日期升序)")
+    public Result<Object> sendTrend7d() {
+        return marketEmailsService.getLast7DaysSendTrend();
+    }
+
+    @GetMapping("/account-total")
+    @ApiOperation("营销: 按发件箱 id 批量统计,返回 Map<outboxId, 统计>(总发送量、回复数、回复率、最新发送时间;outboxIds=1,2 或 outboxIds=1&outboxIds=2)")
+    public Result<Map<Long, Object>> accountStats(@RequestParam("outboxIds") List<Long> outboxIds) {
+        return marketEmailsService.getAccountStats(outboxIds);
+    }
+
     @PostMapping("/test")
     @ApiOperation("获取邮件信息(form 传 ids,多个用逗号分隔或重复 key:ids=1,2 或 ids=1&ids=2)")
     public Result<?> emailList(@RequestParam("ids") List<Long> ids) {

+ 156 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MailMessageDTO.java

@@ -0,0 +1,156 @@
+package com.storlead.trade.dto;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 邮件服务(B 服务)邮件详情,对应 {@code /mkt/list_by_ids} 返回的 result 数组元素。
+ * <p>
+ * 字段名与 B 服务返回的 JSON key 一一对应;B 服务源码不在本仓库,字段以其实际返回为准。
+ */
+@Data
+@ApiModel(value = "MailMessageDTO", description = "邮件服务返回的邮件详情")
+public class MailMessageDTO {
+
+    @ApiModelProperty(value = "邮件id")
+    private Long id;
+
+    @ApiModelProperty(value = "邮箱账户id")
+    private Long accountId;
+
+    @ApiModelProperty(value = "附件(B 服务原始结构未定,原样透传)")
+    private Object attachments;
+
+    @ApiModelProperty(value = "业务批次号")
+    private String bizBatchNo;
+
+    @ApiModelProperty(value = "业务引用")
+    private String bizRef;
+
+    @ApiModelProperty(value = "邮件正文")
+    private String content;
+
+    @ApiModelProperty(value = "创建人")
+    private Long createBy;
+
+    @ApiModelProperty(value = "创建时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    @ApiModelProperty(value = "是否删除:0否 1是")
+    private Integer deleted;
+
+    @ApiModelProperty(value = "邮件发送时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date emailSendTime;
+
+    @ApiModelProperty(value = "邮件大小(字节)")
+    private Long emailSize;
+
+    @ApiModelProperty(value = "是否启用")
+    private Boolean enabled;
+
+    @ApiModelProperty(value = "失败原因")
+    private String failReason;
+
+    @ApiModelProperty(value = "首次打开时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date firstOpenTime;
+
+    @ApiModelProperty(value = "首次回复时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date firstReplyTime;
+
+    @ApiModelProperty(value = "所在文件夹(如 INBOX)")
+    private String folder;
+
+    @ApiModelProperty(value = "发件人地址")
+    private String fromAdd;
+
+    @ApiModelProperty(value = "发件人名称")
+    private String fromName;
+
+    @ApiModelProperty(value = "被回复的邮件 message-id")
+    private String inReplyTo;
+
+    @ApiModelProperty(value = "回复地址")
+    private String mailReplyTo;
+
+    @ApiModelProperty(value = "noCeHead 标记")
+    private Integer noCeHead;
+
+    @ApiModelProperty(value = "是否已读:0否 1是")
+    private Integer isRead;
+
+    @ApiModelProperty(value = "是否追踪:0否 1是")
+    private Integer isTrack;
+
+    @ApiModelProperty(value = "打开次数")
+    private Integer openCount;
+
+    @ApiModelProperty(value = "是否已打开:0否 1是")
+    private Integer opened;
+
+    @ApiModelProperty(value = "组织id")
+    private Long orgId;
+
+    @ApiModelProperty(value = "归属人")
+    private Long ownerBy;
+
+    @ApiModelProperty(value = "收件人(多个逗号隔开)")
+    private String recipient;
+
+    @ApiModelProperty(value = "密送人")
+    private String recipientBcc;
+
+    @ApiModelProperty(value = "抄送人")
+    private String recipientCc;
+
+    @ApiModelProperty(value = "收件时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date recipientDate;
+
+    @ApiModelProperty(value = "是否已回复:0否 1是")
+    private Integer replied;
+
+    @ApiModelProperty(value = "回复邮件 message-id")
+    private String replyMsgId;
+
+    @ApiModelProperty(value = "发送日期")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date sendDate;
+
+    @ApiModelProperty(value = "发送时间戳(毫秒)")
+    private Long sendTime;
+
+    @ApiModelProperty(value = "排序")
+    private Integer sort;
+
+    @ApiModelProperty(value = "邮件状态:1正常")
+    private Integer status;
+
+    @ApiModelProperty(value = "邮件主题")
+    private String subject;
+
+    @ApiModelProperty(value = "追踪 token")
+    private String trackToken;
+
+    @ApiModelProperty(value = "更新人")
+    private Long updateBy;
+
+    @ApiModelProperty(value = "更新时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 10 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/SopDetailEntity.java

@@ -8,6 +8,8 @@ import com.storlead.framework.mybatis.entity.SysBaseField;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 
+import java.math.BigDecimal;
+
 @Data
 @TableName("sop_detail")
 public class SopDetailEntity extends SysBaseField {
@@ -40,6 +42,14 @@ public class SopDetailEntity extends SysBaseField {
     @TableField("type")
     private String type;
 
+    @ApiModelProperty(value = "发送数")
+    @TableField("send_number")
+    private Integer sendNumber;
+
+    @ApiModelProperty(value = "打开率")
+    @TableField("open_rate")
+    private BigDecimal openRate;
+
 //    @ApiModelProperty(value = "创建时间")
 //    @TableField("create_time")
 //    private LocalDateTime createTime;

+ 27 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketEmailsService.java

@@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import org.springframework.scheduling.annotation.Async;
 
 import java.util.List;
+import java.util.Map;
 
 public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
 
@@ -93,6 +94,29 @@ public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
      */
     Result<Object> getManagementPage(MarketEmailsDTO dto);
 
+    /**
+     * 今日邮件统计(仪表盘四张卡片):
+     * 今日发送 = expect_send_time 在今天且 status=1(发送成功);
+     * 今日打开 = first_open_time 在今天;
+     * 今日回复 = first_reply_time 在今天;
+     * 今日拒收 = expect_send_time 在今天且 has_refuse=1(已拒收)。
+     * 时间窗口为今天 00:00:00 ~ 明天 00:00:00(左闭右开)。
+     */
+    Result<Object> getTodayStats();
+
+    /**
+     * 最近 7 天(含今天,共 7 个点)邮件发送趋势:按 expect_send_time 归到当天,
+     * 只统计 status=1(发送成功);某天无数据时补 0,按日期升序返回。
+     */
+    Result<Object> getLast7DaysSendTrend();
+
+    /**
+     * 按发件箱 id 批量聚合:总发送量、回复数、回复率、最新发送时间。
+     * 口径与今日发送一致:is_delete=0 且 status=1;回复数再叠加 has_reply=1。
+     * 返回 Map&lt;outboxId, 统计&gt;;入参只接受账号 id,无数据的 id 补 0。
+     */
+    Result<Map<Long, Object>> getAccountStats(List<Long> outboxIds);
+
     /***
       * @Description: 通过id/marketingCampaignId/客户id关联的邮件状态
       * @Param:
@@ -134,4 +158,7 @@ public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
      * @return 被置为已取消的邮件条数
      */
     int cancelOverduePending();
+
+    @Async
+    void updateEmailContent(MarketEmailsEntity marketEmailsEntity);
 }

+ 18 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/SopDetailService.java

@@ -36,4 +36,22 @@ public interface SopDetailService extends IService<SopDetailEntity> {
      * 删除 SOP 明细(单条/批量,逻辑删除)
      */
     Result<Object> delete(List<Long> ids);
+
+    /**
+     * 统计各 SOP 步骤的邮件发送数与打开率,并写回 sop_detail。
+     * <p>
+     * 关联链路:market_emails.marketing_campaign_sop_detail_id → marketing_campaign_sop_detail.id
+     * → sop_detail_id → sop_detail.id(同一个 SOP 模板可能被多个营销活动复用,故按 sop_detail 累加)。
+     * <p>
+     * 口径:
+     * <ul>
+     *   <li>send_number(发送数):该步骤下 status=1(发送成功)的邮件数</li>
+     *   <li>open_rate(打开率):已读邮件数 / 发送数 * 100,四舍五入取整(列是 decimal(10,0))</li>
+     * </ul>
+     * 已读判定 has_reader=1(与 MarketEmailsStatusSync 的回写口径一致);发送数为 0 时打开率记 0。
+     * 无关联邮件的步骤会被重置为 0,保证是「全量重算」而非增量累加。
+     *
+     * @return 实际写库的条数
+     */
+    int countEmailStats();
 }

+ 77 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerTradeServiceImpl.java

@@ -9,6 +9,8 @@ import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
+import com.storlead.trade.client.MarketEmailsClient;
+import com.storlead.trade.dto.MailMessageDTO;
 import com.storlead.trade.dto.SysDictDetailResponseDTO;
 import com.storlead.trade.dto.CustomerDTO;
 import com.storlead.trade.entity.CustomerCompanyEntity;
@@ -39,6 +41,7 @@ import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -46,6 +49,8 @@ import java.util.Set;
 import java.util.stream.Collectors;
 import lombok.extern.slf4j.Slf4j;
 
+import javax.annotation.Resource;
+
 @Slf4j
 @Service
 @DS(DSConstants.DATASOURCE_TRADE)
@@ -57,6 +62,8 @@ public class CustomerTradeServiceImpl
     private final LiaisonTradeService liaisonTradeService;
     private final CustomerAnalysisResultEntityService customerAnalysisResultEntityService;
     private final SysDictService sysDictService;
+    @Resource
+    private MarketEmailsClient marketEmailsClient;
     /**
      * 直接注入 Mapper 而非 MarketEmailsService:MarketEmailsServiceImpl 依赖 CustomerTradeService,
      * 而本类是构造器注入,再引 MarketEmailsService 会形成构造器循环依赖,启动时抛
@@ -287,6 +294,12 @@ public class CustomerTradeServiceImpl
         List<MarketEmailsEntity> sentEmailList = findSentEmailList(customerId);
         vo.setInteractionRecordList(sentEmailList);
 
+        //获取全部的邮件id
+
+        List<Long> allEmailIdList = extractAllEmailIds(sentEmailList);
+        vo.setEmailIdList(allEmailIdList);
+        // 邮件详情:按邮件id去 B 服务拉取;失败降级为空数组,不影响客户详情主流程
+        vo.setEmailDetailList(findMailDetailList(allEmailIdList));
         // 最后联系时间 / 天数:取互动记录首条,省掉一次查询
         Date lastContactTime = sentEmailList.isEmpty() ? null : sentEmailList.get(0).getExpectSendTime();
         vo.setLastContactTime(lastContactTime);
@@ -307,6 +320,70 @@ public class CustomerTradeServiceImpl
                         .orderByDesc(MarketEmailsEntity::getExpectSendTime));
     }
 
+    /**
+     * 抽取互动记录里的全部邮件id:每封邮件的 emailId + replyEmailIds(逗号分隔)展开后的并集,
+     * 去重并保持首次出现的顺序(LinkedHashSet);无记录返回空 List(不返回 null)。
+     */
+    private List<Long> extractAllEmailIds(List<MarketEmailsEntity> sentEmailList) {
+        Set<Long> idSet = new LinkedHashSet<>();
+        if (CollectionUtils.isEmpty(sentEmailList)) {
+            return new ArrayList<>();
+        }
+        for (MarketEmailsEntity email : sentEmailList) {
+            if (email == null) {
+                continue;
+            }
+            addEmailId(idSet, email.getEmailId());
+            if (StringUtils.hasText(email.getReplyEmailIds())) {
+                for (String part : email.getReplyEmailIds().split(",")) {
+                    addEmailId(idSet, part);
+                }
+            }
+        }
+        return new ArrayList<>(idSet);
+    }
+
+    /**
+     * 单个邮件id的安全转换(Long 或数字字符串均可);null / 空白 / 非法值直接忽略。
+     */
+    private void addEmailId(Set<Long> idSet, Object value) {
+        if (value == null) {
+            return;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty()) {
+            return;
+        }
+        try {
+            idSet.add(Long.valueOf(text));
+        } catch (NumberFormatException ex) {
+            log.warn("[extractAllEmailIds] 非法邮件id已忽略: {}", text);
+        }
+    }
+
+    /**
+     * 按邮件 id 调邮件服务 {@code /mkt/list_by_ids} 拉取邮件详情。
+     * ids 为空直接返回空 List;远程失败 / 返回失败时降级为空 List 并打 warn,
+     * 不让邮件服务的抖动拖垮客户详情主接口。
+     */
+    private List<MailMessageDTO> findMailDetailList(List<Long> emailIds) {
+        if (CollectionUtils.isEmpty(emailIds)) {
+            return new ArrayList<>();
+        }
+        try {
+            Result<List<MailMessageDTO>> result = marketEmailsClient.emailList(emailIds);
+            if (result == null || !result.isSuccess()) {
+                log.warn("[findMailDetailList] 拉取邮件详情失败, emailIds={}, resp={}",
+                        emailIds, result == null ? "null" : result.getMessage());
+                return new ArrayList<>();
+            }
+            return result.getResult() == null ? new ArrayList<>() : result.getResult();
+        } catch (Exception ex) {
+            log.warn("[findMailDetailList] 调用邮件服务异常, emailIds={}, msg={}", emailIds, ex.getMessage());
+            return new ArrayList<>();
+        }
+    }
+
     /**
      * 按自然日计算 date 距今的天数(当天为 0);date 为 null 时返回 null。
      */

+ 273 - 6
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.TypeReference;
 import com.alibaba.fastjson.parser.Feature;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -38,8 +39,12 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 
 import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -94,10 +99,13 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
     @Resource
     private LiaisonTradeService liaisonTradeService;
 
-    @Autowired
+    @Resource
     @Lazy
     private RedissonClient redissonClient;
 
+    @Resource
+    private CustomerAnalysisResultEntityService customerAnalysisResultService;
+
 
     @Override
     public Result<Object> getList(MarketEmailsDTO dto) {
@@ -530,6 +538,44 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         return rows;
     }
 
+    @Override
+    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);
+
+        this.updateById(marketEmailsEntity);
+    }
+
     /**
      * 回复邮件的客户列表(按 marketingCampaignId 过滤 + hasReply=1 + recipient 聚合)
      * 不分页:全部/已保护/营销暂停 三个 Tab 由前端在结果中过滤计数。
@@ -931,6 +977,207 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         return Result.result(voPage);
     }
 
+    @Override
+    public Result<Object> getTodayStats() {
+        // 统计窗口:今天 00:00:00 ~ 明天 00:00:00(左闭右开),按服务器时区
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+        LocalDateTime startDay = LocalDate.now().atStartOfDay();
+        String start = startDay.format(formatter);
+        String end = startDay.plusDays(1).format(formatter);
+
+        // 单条 SQL 条件聚合(start/end 由服务端按当天生成,非用户输入,无注入风险):
+        //   今日发送:expect_send_time 在今天且已发送成功(status=1,已取消/待发送/发送中不算)
+        //   今日打开:first_open_time 在今天
+        //   今日回复:first_reply_time 在今天
+        //   今日拒收:expect_send_time 在今天且已拒收(has_refuse=1)
+        QueryWrapper<MarketEmailsEntity> qw = new QueryWrapper<>();
+        qw.eq("is_delete", CommonConstant.DEL_FLAG_0);
+        qw.select(
+                "COUNT(CASE WHEN status = 1 AND expect_send_time >= '" + start + "' AND expect_send_time < '" + end + "' THEN 1 END) AS send_count",
+                "COUNT(CASE WHEN first_open_time >= '" + start + "' AND first_open_time < '" + end + "' THEN 1 END) AS open_count",
+                "COUNT(CASE WHEN first_reply_time >= '" + start + "' AND first_reply_time < '" + end + "' THEN 1 END) AS reply_count",
+                "COUNT(CASE WHEN IFNULL(has_refuse, 0) = 1 AND expect_send_time >= '" + start + "' AND expect_send_time < '" + end + "' THEN 1 END) AS refuse_count");
+        List<Map<String, Object>> rows = this.listMaps(qw);
+
+        MarketEmailsTodayStatsVO vo = new MarketEmailsTodayStatsVO();
+        if (!CollectionUtils.isEmpty(rows) && rows.get(0) != null) {
+            Map<String, Object> row = rows.get(0);
+            vo.setTodaySendCount(toInteger(row.get("send_count")));
+            vo.setTodayOpenCount(toInteger(row.get("open_count")));
+            vo.setTodayReplyCount(toInteger(row.get("reply_count")));
+            vo.setTodayRefuseCount(toInteger(row.get("refuse_count")));
+        }
+        return Result.result(vo);
+    }
+
+    @Override
+    public Result<Object> getLast7DaysSendTrend() {
+        LocalDate today = LocalDate.now();
+        // 窗口:今天往前推 6 天 的 00:00 ~ 明天 00:00,共 7 天(含今天)
+        LocalDateTime startDay = today.minusDays(6).atStartOfDay();
+        LocalDateTime endDay = today.plusDays(1).atStartOfDay();
+        Date startDate = Date.from(startDay.atZone(ZoneId.systemDefault()).toInstant());
+        Date endDate = Date.from(endDay.atZone(ZoneId.systemDefault()).toInstant());
+
+        // 按天聚合;只统计发送成功(status=1),与「今日发送」口径一致
+        QueryWrapper<MarketEmailsEntity> qw = new QueryWrapper<>();
+        qw.select("DATE(expect_send_time) AS day", "COUNT(*) AS cnt");
+        qw.eq("is_delete", CommonConstant.DEL_FLAG_0);
+        qw.eq("status", 1);
+        qw.ge("expect_send_time", startDate);
+        qw.lt("expect_send_time", endDate);
+        qw.groupBy("DATE(expect_send_time)");
+        List<Map<String, Object>> rows = this.listMaps(qw);
+
+        Map<String, Integer> countMap = new HashMap<>();
+        if (!CollectionUtils.isEmpty(rows)) {
+            for (Map<String, Object> row : rows) {
+                if (row == null) {
+                    continue;
+                }
+                String day = toDayKey(row.get("day"));
+                if (day != null) {
+                    countMap.put(day, toInteger(row.get("cnt")));
+                }
+            }
+        }
+
+        // 补齐 7 天:没有数据的天补 0,按日期升序(最旧 → 今天)
+        List<MarketEmailsTrendVO> list = new ArrayList<>();
+        for (int i = 6; i >= 0; i--) {
+            String day = today.minusDays(i).toString();
+            MarketEmailsTrendVO vo = new MarketEmailsTrendVO();
+            vo.setDate(day);
+            vo.setSendCount(countMap.getOrDefault(day, 0));
+            list.add(vo);
+        }
+        return Result.result(list);
+    }
+
+    @Override
+    public Result<Map<Long, Object>> getAccountStats(List<Long> outboxIds) {
+        Map<Long, Object> result = new LinkedHashMap<>();
+        if (CollectionUtils.isEmpty(outboxIds)) {
+            return Result.result(result);
+        }
+        List<Long> ids = outboxIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        if (ids.isEmpty()) {
+            return Result.result(result);
+        }
+        for (Long id : ids) {
+            result.put(id, emptyAccountStats(id));
+        }
+
+        QueryWrapper<MarketEmailsEntity> qw = new QueryWrapper<>();
+        qw.select(
+                "outbox_id",
+                "COUNT(*) AS send_count",
+                "COUNT(CASE WHEN IFNULL(has_reply, 0) = 1 THEN 1 END) AS reply_count",
+                "MAX(expect_send_time) AS last_send_time");
+        qw.eq("is_delete", CommonConstant.DEL_FLAG_0);
+        qw.eq("status", 1);
+        qw.in("outbox_id", ids);
+        qw.groupBy("outbox_id");
+        List<Map<String, Object>> rows = this.listMaps(qw);
+        if (CollectionUtils.isEmpty(rows)) {
+            return Result.result(result);
+        }
+        for (Map<String, Object> row : rows) {
+            if (row == null) {
+                continue;
+            }
+            Long id = toLongOrNull(row.get("outbox_id"));
+            if (id == null || !result.containsKey(id)) {
+                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);
+    }
+
+    private MarketEmailsAccountStatsVO emptyAccountStats(Long outboxId) {
+        MarketEmailsAccountStatsVO vo = new MarketEmailsAccountStatsVO();
+        vo.setOutboxId(outboxId);
+        vo.setSendCount(0);
+        vo.setReplyCount(0);
+        vo.setReplyRate(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
+        return vo;
+    }
+
+    /**
+     * 分组键转 yyyy-MM-dd:JDBC 可能返回 java.sql.Date / java.sql.Timestamp / 字符串。
+     * java.sql.Date#toInstant 会抛 UnsupportedOperationException,必须优先判断。
+     */
+    private String toDayKey(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof java.sql.Date) {
+            return ((java.sql.Date) value).toLocalDate().toString();
+        }
+        if (value instanceof Date) {
+            return ((Date) value).toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString();
+        }
+        String text = value.toString().trim();
+        return text.length() > 10 ? text.substring(0, 10) : text;
+    }
+
+    /**
+     * 聚合结果转 Integer:MySQL 的 COUNT/SUM 经 JDBC 可能是 Long / BigInteger / BigDecimal,null 记 0。
+     */
+    private Integer toInteger(Object value) {
+        if (value == null) {
+            return 0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        try {
+            return Integer.valueOf(value.toString().trim());
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    /** 聚合字段转 Long,空值保持 null。 */
+    private Long toLongOrNull(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        try {
+            return Long.valueOf(value.toString().trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private Date toDate(Object value) {
+        if (value instanceof Date) {
+            return (Date) value;
+        }
+        return null;
+    }
+
+    private BigDecimal toReplyRate(int sendCount, int replyCount) {
+        if (sendCount <= 0) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        return BigDecimal.valueOf(replyCount)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(BigDecimal.valueOf(sendCount), 2, RoundingMode.HALF_UP);
+    }
+
     @Override
     public void updateCustomerEmailStatus(MarketEmailsCustomerVO marketEmailsCustomerVO) {
         LambdaQueryWrapper<MarketEmailsEntity> queryWrapper = new LambdaQueryWrapper<>();
@@ -1195,9 +1442,11 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         queryStopWrapper.eq(MarketEmailsEntity::getHasReply, 1);
         queryStopWrapper.eq(MarketEmailsEntity::getHasReader, 1);
         List<MarketEmailsEntity> marketEmailsStopList = this.list(queryStopWrapper);
-        List<Long> customerIdList = new ArrayList<>();
+        List<Long> customerIdList;
         if (!CollectionUtils.isEmpty(marketEmailsStopList)){
             customerIdList = marketEmailsStopList.stream().map(MarketEmailsEntity::getCustomerId).collect(Collectors.toList());
+        } else {
+            customerIdList = new ArrayList<>();
         }
 
 
@@ -1211,16 +1460,34 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         queryWrapper.between(MarketEmailsEntity::getExpectSendTime, start, end);
 
         queryWrapper.eq(MarketEmailsEntity::getStatus, -1);
-        queryWrapper.notIn(!CollectionUtils.isEmpty(customerIdList), MarketEmailsEntity::getCustomerId, customerIdList);
+//        queryWrapper.notIn(!CollectionUtils.isEmpty(customerIdList), MarketEmailsEntity::getCustomerId, customerIdList);
         queryWrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
-        queryWrapper.eq(MarketEmailsEntity::getHasProtect,0);
-        queryWrapper.eq(MarketEmailsEntity::getHasSuspend,0);
+//        queryWrapper.eq(MarketEmailsEntity::getHasProtect,0);
+//        queryWrapper.eq(MarketEmailsEntity::getHasSuspend,0);
         queryWrapper.in(MarketEmailsEntity::getMarketingCampaignId, marketingCampaignIdList);
-        List<MarketEmailsEntity> marketEmailList = this.list(queryWrapper);
+        List<MarketEmailsEntity> marketEmailFirstList = this.list(queryWrapper);
 
+        if (CollectionUtils.isEmpty(marketEmailFirstList)){
+            return;
+        }
+        List<MarketEmailsEntity> marketEmailList = marketEmailFirstList;
+                //获取客户id在customerIdList或者已开启保护或者已暂停的marketEmailList
+                List<MarketEmailsEntity> marketEmailNotList = marketEmailFirstList.stream()
+                        .filter(marketEmail -> customerIdList.contains(marketEmail.getCustomerId()) ||
+                                marketEmail.getHasProtect() == 1 ||
+                                marketEmail.getHasSuspend() == 1)
+                        .collect(Collectors.toList());
+        if (!CollectionUtils.isEmpty(marketEmailNotList)) {
+            for (MarketEmailsEntity marketEmail : marketEmailNotList) {
+                marketEmail.setStatus(-3);
+            }
+            this.updateBatchById(marketEmailNotList);
+            marketEmailList.removeAll(marketEmailNotList);
+        }
         if (CollectionUtils.isEmpty(marketEmailList)){
             return;
         }
+
         log.info("queryWrapper: {}", queryWrapper);
         log.info("marketEmailList: {}", marketEmailList);
         for (MarketEmailsEntity marketEmail : marketEmailList){

+ 49 - 15
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignSopDetailServiceImpl.java

@@ -4,6 +4,7 @@ import com.baomidou.dynamic.datasource.annotation.DS;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.common.result.Result;
 import com.storlead.mail.pojo.entity.MktMailMessageEntity;
@@ -17,12 +18,14 @@ import com.storlead.trade.service.*;
 import com.storlead.trade.service.impl.support.TradeDataSourceServiceImpl;
 import com.storlead.trade.vo.CustomerVO;
 import org.springframework.beans.BeanUtils;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 
 import javax.annotation.Resource;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -43,6 +46,9 @@ public class MarketingCampaignSopDetailServiceImpl
     private MktMailMessageService mktMailMessageService;
     @Resource
     private SysDictService sysDictService;
+    @Resource
+    @Lazy
+    private MarketingCampaignService marketingCampaignService;
 
 
     @Override
@@ -62,6 +68,21 @@ public class MarketingCampaignSopDetailServiceImpl
             return Result.error("存在已发送的邮件,不能进行修改");
         }
 
+        // 要更新邮件内容时,先确认该步骤下确实有邮件可写。
+        // 放在 updateById 之前:否则步骤已改、内容却没写进去,前后端数据就对不上了。
+        List<MarketEmailsEntity> pendingEmailList = Collections.emptyList();
+        if (!ObjectUtils.isEmpty(dto.getEmailContent())) {
+            LambdaQueryWrapper<MarketEmailsEntity> pendingWrapper = new LambdaQueryWrapper<>();
+            pendingWrapper.eq(MarketEmailsEntity::getMarketingCampaignSopDetailId, dto.getId());
+            pendingWrapper.eq(MarketEmailsEntity::getMarketingCampaignId, dto.getMarketingCampaignId());
+            pendingWrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+            pendingEmailList = marketEmailsService.list(pendingWrapper);
+            if (CollectionUtils.isEmpty(pendingEmailList)) {
+                MarketingCampaignEntity marketingCampaignEntity = marketingCampaignService.getById(dto.getMarketingCampaignId());
+                marketEmailsService.addSendTime(marketingCampaignEntity);
+                return Result.error("邮件还未生成或生成失败,请稍后重试");
+            }
+        }
 
         MarketingCampaignSopDetailEntity entity = new MarketingCampaignSopDetailEntity();
         BeanUtils.copyProperties(dto, entity);
@@ -69,22 +90,28 @@ public class MarketingCampaignSopDetailServiceImpl
         if (!isOk) {
             return Result.error("修改失败");
         }
-        if (!ObjectUtils.isEmpty(dto.getEmailContent())){
+        if (!ObjectUtils.isEmpty(dto.getEmailContent())) {
             //写入每个客户的邮件内容
-            this.updateEmailContent(dto.getId(),dto.getEmailSubject(), dto.getEmailContent(), dto.getMarketingCampaignId());
+            Result<?> writeResult = this.updateEmailContent(pendingEmailList, dto.getEmailSubject(), dto.getEmailContent());
+            if (writeResult != null) {
+                return writeResult;
+            }
         }
         return Result.result(entity);
     }
 
-    private void updateEmailContent(Long id, String emailSubject, String emailContent, Long marketingCampaignId) {
-        //查询营销活动关联的所有客户
-//        MarketingCampaignSopDetailEntity entity = this.getById(id);
-        LambdaQueryWrapper<MarketEmailsEntity> marketEmailsEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
-        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getMarketingCampaignSopDetailId, id);
-        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getMarketingCampaignId, marketingCampaignId);
-        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getIsDelete, 0);
-        List<MarketEmailsEntity> marketEmailsEntityList = marketEmailsService.list(marketEmailsEntityLambdaQueryWrapper);
-
+    /**
+     * 把邮件模板内容写入该步骤下的每封邮件(含 ${变量}$ 占位符替换)。
+     * <p>
+     * 注意:这里<strong>不抛异常</strong>。抛 RuntimeException 会走全局异常处理器,
+     * 返回的是 code=900000 的兜底结构、HTTP 状态码也可能不是 200,前端拦截器往往识别不了,
+     * 表现为「接口一直卡着没有任何返回」。业务性失败统一返回 {@link Result},由上层直接透传。
+     *
+     * @param marketEmailsEntityList 该步骤下待写入的邮件(调用方已保证非空)
+     * @return null 表示成功;非 null 为失败原因,由上层直接透传给前端
+     */
+    private Result<?> updateEmailContent(List<MarketEmailsEntity> marketEmailsEntityList,
+                                         String emailSubject, String emailContent) {
         List<Long> customerIdList = marketEmailsEntityList.stream().map(MarketEmailsEntity::getCustomerId).collect(Collectors.toList());
         CustomerDTO dto = new CustomerDTO();
         dto.setIdList(customerIdList);
@@ -92,7 +119,7 @@ public class MarketingCampaignSopDetailServiceImpl
         dto.setPageSize(customerIdList.size());
         IPage<CustomerVO> customerPageList = customerService.getList(dto);
         if (customerPageList == null || customerPageList.getRecords() == null || customerPageList.getRecords().size() == 0) {
-            return;
+            return Result.error("未查询到关联的客户,邮件内容写入失败");
         }
         List<CustomerVO> customerVOList = customerPageList.getRecords();
 
@@ -168,13 +195,20 @@ public class MarketingCampaignSopDetailServiceImpl
 //            }
 
         }
-        boolean isOk = marketEmailsService.updateBatchById(marketEmailsEntityList);
-        if (!isOk) {
-            return;
+        for (MarketEmailsEntity marketEmailsEntity : marketEmailsEntityList){
+            //异步修改邮件内容并写入
+
+            marketEmailsService.updateEmailContent(marketEmailsEntity);
+
+
         }
+//        if (!isOk) {
+//            return Result.error("邮件内容写入失败");
+//        }
 
         //异步写入邮件发送时间及内容
 //        this.insertMailMessage(marketEmailsEntityList,customerEntityMap);
+        return null;
     }
 
     @Async

+ 166 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/SopDetailServiceImpl.java

@@ -2,6 +2,7 @@ package com.storlead.trade.service.impl;
 
 import com.baomidou.dynamic.datasource.annotation.DS;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -12,7 +13,11 @@ import com.storlead.trade.dto.SopDetailCreateDTO;
 import com.storlead.trade.dto.SopDetailDTO;
 import com.storlead.trade.dto.SopDetailResponseDTO;
 import com.storlead.trade.dto.SopDetailUpdateDTO;
+import com.storlead.trade.entity.MarketingCampaignSopDetailEntity;
+import com.storlead.trade.entity.MarketEmailsEntity;
 import com.storlead.trade.entity.SopDetailEntity;
+import com.storlead.trade.mapper.MarketingCampaignSopDetailMapper;
+import com.storlead.trade.mapper.MarketEmailsMapper;
 import com.storlead.trade.mapper.SopDetailMapper;
 import com.storlead.trade.service.SopDetailService;
 import com.storlead.trade.service.impl.support.TradeDataSourceServiceImpl;
@@ -23,9 +28,14 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * SOP明细 Service实现类
@@ -40,6 +50,17 @@ public class SopDetailServiceImpl
         extends TradeDataSourceServiceImpl<SopDetailMapper, SopDetailEntity>
         implements SopDetailService {
 
+    /**
+     * 这里注入 Mapper 而不是 Service:
+     * MarketingCampaignSopDetailServiceImpl 已经注入了 MarketEmailsService,
+     * 而 MarketEmailsServiceImpl 又依赖 SopDetailService —— 若此处再注入 Service 会形成
+     * BeanCurrentlyInCreationException 循环依赖,Mapper 无此问题。
+     */
+    @Resource
+    private MarketingCampaignSopDetailMapper marketingCampaignSopDetailMapper;
+    @Resource
+    private MarketEmailsMapper marketEmailsMapper;
+
     @Override
     public Result<Object> getList(SopDetailDTO dto) {
         if (dto == null) {
@@ -160,6 +181,151 @@ public class SopDetailServiceImpl
         return Result.ok();
     }
 
+    @Override
+    public int countEmailStats() {
+        // 1. marketing_campaign_sop_detail.id → sop_detail_id
+        LambdaQueryWrapper<MarketingCampaignSopDetailEntity> mcsWrapper = new LambdaQueryWrapper<>();
+        mcsWrapper.eq(MarketingCampaignSopDetailEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        mcsWrapper.isNotNull(MarketingCampaignSopDetailEntity::getSopDetailId);
+        List<MarketingCampaignSopDetailEntity> mcsList = marketingCampaignSopDetailMapper.selectList(mcsWrapper);
+
+        Map<Long, Long> sopDetailIdByStep = new HashMap<>();
+        if (!CollectionUtils.isEmpty(mcsList)) {
+            for (MarketingCampaignSopDetailEntity e : mcsList) {
+                if (e.getId() == null || e.getSopDetailId() == null) {
+                    continue;
+                }
+                sopDetailIdByStep.put(e.getId(), e.getSopDetailId());
+            }
+        }
+
+        // 2. 全量取出待刷新的 sop_detail(无邮件的步骤也要重置为 0)
+        LambdaQueryWrapper<SopDetailEntity> sopWrapper = new LambdaQueryWrapper<>();
+        sopWrapper.eq(SopDetailEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        sopWrapper.select(SopDetailEntity::getId, SopDetailEntity::getSendNumber, SopDetailEntity::getOpenRate);
+        List<SopDetailEntity> sopDetailList = this.list(sopWrapper);
+        if (CollectionUtils.isEmpty(sopDetailList)) {
+            log.info("[sopDetail统计] 无有效 SOP 步骤,跳过");
+            return 0;
+        }
+
+        // 3. 按 marketing_campaign_sop_detail_id 聚合邮件(一条 SQL,只扫一遍表)
+        Map<Long, int[]> statBySopDetail = new HashMap<>();
+        if (!sopDetailIdByStep.isEmpty()) {
+            QueryWrapper<MarketEmailsEntity> qw = new QueryWrapper<>();
+            qw.select(
+                    "marketing_campaign_sop_detail_id AS step_id",
+                    "COUNT(CASE WHEN status = 1 THEN 1 END) AS send_cnt",
+                    "COUNT(CASE WHEN status = 1 AND IFNULL(has_reader, 0) = 1 THEN 1 END) AS open_cnt");
+            qw.eq("is_delete", CommonConstant.DEL_FLAG_0);
+            qw.isNotNull("marketing_campaign_sop_detail_id");
+            qw.groupBy("marketing_campaign_sop_detail_id");
+            List<Map<String, Object>> rows = marketEmailsMapper.selectMaps(qw);
+
+            if (!CollectionUtils.isEmpty(rows)) {
+                for (Map<String, Object> row : rows) {
+                    if (row == null) {
+                        continue;
+                    }
+                    Long stepId = toLong(row.get("step_id"));
+                    Long sopDetailId = stepId == null ? null : sopDetailIdByStep.get(stepId);
+                    if (sopDetailId == null) {
+                        // 邮件挂的步骤已删除 / 未关联 sop_detail,不计入任何步骤
+                        continue;
+                    }
+                    int[] agg = statBySopDetail.computeIfAbsent(sopDetailId, k -> new int[2]);
+                    agg[0] += toInteger(row.get("send_cnt"));
+                    agg[1] += toInteger(row.get("open_cnt"));
+                }
+            }
+        }
+
+        // 4. 逐个写回(值没变的不发 SQL)
+        int updated = 0;
+        for (SopDetailEntity sopDetail : sopDetailList) {
+            int[] agg = statBySopDetail.get(sopDetail.getId());
+            int sendNumber = agg == null ? 0 : agg[0];
+            int openNumber = agg == null ? 0 : agg[1];
+            BigDecimal openRate = calcRate(openNumber, sendNumber);
+
+            if (eqInt(sendNumber, sopDetail.getSendNumber()) && eqRate(openRate, sopDetail.getOpenRate())) {
+                continue;
+            }
+            LambdaUpdateWrapper<SopDetailEntity> updateWrapper = new LambdaUpdateWrapper<>();
+            updateWrapper.eq(SopDetailEntity::getId, sopDetail.getId());
+            updateWrapper.set(SopDetailEntity::getSendNumber, sendNumber);
+            updateWrapper.set(SopDetailEntity::getOpenRate, openRate);
+            if (this.update(updateWrapper)) {
+                updated++;
+            }
+        }
+        log.info("[sopDetail统计] 完成,sop步骤总数={},写库={}条", sopDetailList.size(), updated);
+        return updated;
+    }
+
+    /**
+     * 打开率:已读邮件数 / 发送数 * 100,四舍五入保留整数。
+     * sop_detail.open_rate 是 decimal(10,0),小数位会被 MySQL 直接截断,所以这里统一先取整。
+     * 发送数为 0 时记 0,避免出现除零错误。
+     */
+    private BigDecimal calcRate(int numerator, int denominator) {
+        if (denominator <= 0 || numerator <= 0) {
+            return BigDecimal.ZERO;
+        }
+        return BigDecimal.valueOf(numerator)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(BigDecimal.valueOf(denominator), 0, RoundingMode.HALF_UP);
+    }
+
+    private boolean eqInt(Integer a, Integer b) {
+        int left = a == null ? 0 : a;
+        int right = b == null ? 0 : b;
+        return left == right;
+    }
+
+    /**
+     * 打开率比较:把 null 视作 0。MySQL 返回的 decimal 精度可能带小数位,统一用 intValue 比对。
+     */
+    private boolean eqRate(BigDecimal a, BigDecimal b) {
+        int left = a == null ? 0 : a.intValue();
+        int right = b == null ? 0 : b.intValue();
+        return left == right;
+    }
+
+    /**
+     * 聚合结果转 int:MySQL 的 COUNT 经 JDBC 可能是 Long / BigInteger / BigDecimal,null 记 0。
+     */
+    private int toInteger(Object value) {
+        if (ObjectUtils.isEmpty(value)) {
+            return 0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        try {
+            return Integer.parseInt(value.toString().trim());
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    /**
+     * 分组键转 Long:非数字(脏数据)返回 null,由调用方跳过。
+     */
+    private Long toLong(Object value) {
+        if (ObjectUtils.isEmpty(value)) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        try {
+            return Long.valueOf(value.toString().trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
     /**
      * Entity → ResponseDTO
      */

+ 33 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/task/SopDetailCountTask.java

@@ -0,0 +1,33 @@
+package com.storlead.trade.task;
+
+import com.storlead.trade.service.MarketingCampaignService;
+import com.storlead.trade.service.SopDetailService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 每天 3 点:统计 sopDetail 对应邮件的发送数与打开率,写回 sop_detail 表。
+ * <p>
+ * 口径见 {@link com.storlead.trade.service.SopDetailService#countEmailStats()}。
+ */
+@Slf4j
+@Component
+public class SopDetailCountTask {
+
+    @Resource
+    private SopDetailService sopDetailService;
+
+    @Scheduled(cron = "0 0 3 * * ?")
+    public void dispatch() {
+        long start = System.currentTimeMillis();
+        try {
+            int updated = sopDetailService.countEmailStats();
+            log.info("[SopDetail统计] 任务完成 updated={} cost={}ms", updated, System.currentTimeMillis() - start);
+        } catch (Exception e) {
+            log.error("[SopDetail统计] 任务失败 cost={}ms", System.currentTimeMillis() - start, e);
+        }
+    }
+}

+ 7 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/CustomerVO.java

@@ -1,5 +1,6 @@
 package com.storlead.trade.vo;
 
+import com.storlead.trade.dto.MailMessageDTO;
 import com.storlead.trade.entity.CustomerCompanyEntity;
 import com.storlead.trade.entity.CustomerEntity;
 import com.storlead.trade.entity.CustomerAnalysisResultEntity;
@@ -48,4 +49,10 @@ public class CustomerVO extends CustomerEntity {
 
     @ApiModelProperty(value = "互动记录列表(该客户全部已发送成功的邮件,按发送时间倒序;无记录为空数组)")
     private List<MarketEmailsEntity> interactionRecordList;
+
+    @ApiModelProperty(value = "全部邮件id(互动记录中每封邮件的 emailId + replyEmailIds 展开后的并集,已去重;无记录为空数组)")
+    private List<Long> emailIdList;
+
+    @ApiModelProperty(value = "邮件详情列表(按 emailIdList 调邮件服务 /mkt/list_by_ids 得到的详情;拉取失败或无记录为空数组)")
+    private List<MailMessageDTO> emailDetailList;
 }

+ 40 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsAccountStatsVO.java

@@ -0,0 +1,40 @@
+package com.storlead.trade.vo;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 营销邮件-按发件箱账号聚合统计
+ */
+@Data
+@ApiModel(value = "MarketEmailsAccountStatsVO", description = "营销邮件-按邮箱账号统计(发送量/回复数/回复率/最近发送时间)")
+public class MarketEmailsAccountStatsVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "发件箱id")
+    private Long outboxId;
+
+    @ApiModelProperty(value = "总发送量(status=1 发送成功)")
+    private Integer sendCount = 0;
+
+    @ApiModelProperty(value = "回复数(发送成功且 has_reply=1)")
+    private Integer replyCount = 0;
+
+    @ApiModelProperty(value = "回复率(百分比,保留 2 位小数;sendCount=0 时为 0.00)")
+    private BigDecimal replyRate = BigDecimal.ZERO.setScale(2);
+
+    @ApiModelProperty(value = "最新发送时间(该账号下 expect_send_time 最大值)")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+    private Date lastSendTime;
+}

+ 29 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsTodayStatsVO.java

@@ -0,0 +1,29 @@
+package com.storlead.trade.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 营销邮件-今日统计 VO(仪表盘顶部四张卡片)
+ */
+@Data
+@ApiModel(value = "MarketEmailsTodayStatsVO", description = "营销邮件-今日统计(发送/打开/回复/拒收)")
+public class MarketEmailsTodayStatsVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "今日发送(expect_send_time 在今天且 status=1 发送成功;已取消/待发送/发送中不算)")
+    private Integer todaySendCount = 0;
+
+    @ApiModelProperty(value = "今日打开(first_open_time 在今天的邮件数)")
+    private Integer todayOpenCount = 0;
+
+    @ApiModelProperty(value = "今日回复(first_reply_time 在今天的邮件数)")
+    private Integer todayReplyCount = 0;
+
+    @ApiModelProperty(value = "今日拒收(expect_send_time 在今天且 has_refuse=1 已拒收的邮件数)")
+    private Integer todayRefuseCount = 0;
+}

+ 23 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsTrendVO.java

@@ -0,0 +1,23 @@
+package com.storlead.trade.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 邮件发送趋势(按天)VO
+ */
+@Data
+@ApiModel(value = "MarketEmailsTrendVO", description = "营销邮件-按天发送趋势")
+public class MarketEmailsTrendVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "日期(yyyy-MM-dd)")
+    private String date;
+
+    @ApiModelProperty(value = "当天发送数(expect_send_time 在该天且 status=1;无数据补 0)")
+    private Integer sendCount = 0;
+}

+ 1 - 1
ui/smarttrade-platform/src/assets/styles/marketing.css

@@ -431,7 +431,7 @@
 .customer-source h5 { font-size: 13px; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
 .source-bars { display: flex; flex-direction: column; gap: 10px; }
 .source-item { display: flex; align-items: center; gap: 10px; }
-.source-label { width: 70px; font-size: 12px; color: var(--text-secondary); }
+.source-label { width: 55px; font-size: 12px; color: var(--text-secondary);white-space: nowrap;overflow: hidden;text-overflow: ellipsis; }
 .source-bar { flex: 1; height: 8px; background: var(--bg-dark); border-radius: 4px; overflow: hidden; }
 .source-fill { height: 100%; border-radius: 4px; }
 .source-value { width: 40px; font-size: 12px; text-align: right; }

+ 1 - 1
ui/smarttrade-platform/src/assets/styles/settings.css

@@ -57,7 +57,7 @@
 .source-status i { margin-right: 4px; }
 
 /* Overview Cards */
-.sources-overview, .email-overview { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
+.sources-overview, .email-overview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; }
 .overview-card { display: flex; align-items: center; gap: 14px; padding: 18px; background: var(--bg-hover); border-radius: 12px; }
 .overview-icon { width: 48px; height: 48px; background: rgba(99, 102, 241, 0.2); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; color: var(--primary); }
 .overview-icon.email { background: rgba(99, 102, 241, 0.2); }

+ 14 - 5
ui/smarttrade-platform/src/components/addMarketing.vue

@@ -85,7 +85,7 @@
                                 type="date"
                                 format="YYYY-MM-DD"
                                 value-format="YYYY-MM-DD"
-                                placeholder="选择开始时间"
+                                placeholder="选择开始日期"
                             />
                         </div>
                         <div class="form-group">
@@ -95,7 +95,7 @@
                                 type="date"
                                 format="YYYY-MM-DD"
                                 value-format="YYYY-MM-DD"
-                                placeholder="选择结束时间"
+                                placeholder="选择结束日期"
                             />
                         </div>
                     </div>
@@ -290,10 +290,10 @@
                     <div class="customer-source" v-if="customerList.length != 0">
                         <h5><i class="fas fa-chart-pie"></i> 客户来源分布</h5>
                         <div class="source-bars">
-                            <div class="source-item" v-for="(item) in sourceData">
+                            <div class="source-item" v-for="(item, index) in sourceData">
                                 <span class="source-label">{{ item.sourceLabel }}</span>
                                 <div class="source-bar">
-                                    <div class="source-fill" :style="{ width: item.ratio + '%', background: 'var(--success)' }"></div>
+                                    <div class="source-fill" :style="{ width: item.ratio + '%', background: sourceColor[index] }"></div>
                                 </div>
                                 <span class="source-value">{{ item.ratio }}%</span>
                             </div>
@@ -309,7 +309,7 @@
                         <div v-for="ch in availableChannels" :key="ch.id" class="channel-card" :class="{
                             selected: newCampaign.channels.includes(ch.id),
                             disabled: !ch.enabled,
-                        }" @click="ch.enabled && toggleChannel(ch.id)">
+                        }" @click="ch.enabled">
                             <div class="channel-icon" :style="{ background: ch.iconBg, color: ch.iconColor }">
                                 <i :class="ch.icon"></i>
                             </div>
@@ -1054,6 +1054,10 @@
     }
 
     const addCustomSopStep = () => {
+        if(customSopData.value.details.length >= 6) {
+            ElMessage.error('最多只能添加6个步骤')
+            return
+        }
         const lastDay = customSopData.value.details[customSopData.value.details.length - 1]?.days || 0
         customSopData.value.details.push({
             name: '',
@@ -1175,6 +1179,7 @@
         })
     }
 
+    const sourceColor = ['#6366F1', '#F59E0B', '#10B981', '#EF4444', '#3B82F6', '#8B5CF6', '#F43F5E', '#14B8A6', '#F97316', '#EAB308']
     const getSource = () => {
         let params = {
             idList: customerList.value.map(item => item.id).join(',')
@@ -1284,6 +1289,10 @@
     color: var(--text-muted);
 }
 
+.create-modal :deep(.el-cascader .el-input__inner::placeholder) {
+    color: var(--el-text-color-placeholder);
+}
+
 .create-modal :deep(.el-select-dropdown),
 .create-modal :deep(.el-picker-panel) {
     background: var(--bg-card);

+ 8 - 8
ui/smarttrade-platform/src/components/email/email.vue

@@ -29,13 +29,13 @@
                 <div class="overview-label">平均回复率</div>
               </div>
             </div>
-            <div class="overview-card">
+            <!-- <div class="overview-card">
               <div class="overview-icon"><i class="fas fa-shield-alt"></i></div>
               <div class="overview-info">
                 <div class="overview-value">3/3</div>
                 <div class="overview-label">安全认证</div>
               </div>
-            </div>
+            </div> -->
           </div>
 
           <!-- 标签页导航 -->
@@ -56,11 +56,11 @@
                   <span><i class="fas fa-sync"></i>  2026-03-24 09:00</span>
                 </div>
               </div>
-              <div class="account-auth">
+              <!-- <div class="account-auth">
                 <span class="auth-badge" :class="account.SPF ? 'active' : ''"><i class="fas fa-check"></i> SPF</span>
                 <span class="auth-badge" :class="account.DKIM ? 'active' : ''"><i class="fas fa-check"></i> DKIM</span>
                 <span class="auth-badge" :class="account.DMARC ? 'active' : ''"><i class="fas fa-check"></i> DMARC</span>
-              </div>
+              </div> -->
             </div>
           </div>
 
@@ -203,7 +203,7 @@
           </div>
 
           <!-- 安全认证 -->
-          <div class="detail-section">
+          <!-- <div class="detail-section">
             <h5><i class="fas fa-shield-alt"></i> 邮件安全认证状态</h5>
             <div class="auth-grid">
               <div class="auth-card" :class="selectedEmailAccount.SPF ? 'pass' : 'fail'">
@@ -231,10 +231,10 @@
                 <div class="auth-status">{{ selectedEmailAccount.DMARC ? '已配置' : '未配置' }}</div>
               </div>
             </div>
-          </div>
+          </div> -->
 
           <!-- 邮件签名 -->
-          <div class="detail-section">
+          <!-- <div class="detail-section">
             <h5><i class="fas fa-pen"></i> 邮件签名</h5>
             <div class="signature-preview">
               <div class="signature-label">预览效果:</div>
@@ -243,7 +243,7 @@
               </div>
             </div>
             <textarea class="form-textarea signature-textarea" rows="6">{{ selectedEmailAccount.signature }}</textarea>
-          </div>
+          </div> -->
         </div>
         <div class="modal-footer">
           <button class="btn btn-secondary" @click="showEmailDetailModal = false">关闭</button>

+ 1 - 1
ui/smarttrade-platform/src/components/marketing/customerDetail.vue

@@ -28,7 +28,7 @@
                                 <el-option label="暂时搁置" value="66" />
                             </el-select>
                         </el-form-item>
-                        <el-form-item label="" prop="regionValue">
+                        <el-form-item label="" prop="country">
                             <el-cascader style="width: 100%" placeholder="区域名称" :options="region"
                                     v-model="regionValue" clearable filterable @change="getRegion" />
                         </el-form-item>

+ 2 - 2
ui/smarttrade-platform/src/components/marketing/marketingDetail.vue

@@ -140,8 +140,8 @@
                 <span class="sop-steps">{{ sopDetails.length }} 步序列</span>
               </div>
               <div class="sop-timeline-detail">
-                <div class="sop-step-item" :class="{ 'pending': item.sopStatus == 0, 'current': item.sopStatus == 1, 'completed': item.sopStatus == 2 }" v-for="item in sopDetails" :key="item.id">
-                  <div class="step-marker">{{ item.step }}</div>
+                <div class="sop-step-item" :class="{ 'pending': item.sopStatus == 0, 'current': item.sopStatus == 1, 'completed': item.sopStatus == 2 }" v-for="(item, index) in sopDetails" :key="item.id">
+                  <div class="step-marker">{{ index + 1 }}</div>
                   <div class="step-content">
                     <div class="step-title">{{ item.name }}</div>
                     <div class="step-meta">间隔{{ item.days }}天 · 邮件 · {{ item.sopStatus == 0 ? '待执行' : item.sopStatus == 1 ? '进行中' : '已完成' }}</div>