2 Revize 1e7ee33b50 ... 81eb0a9e98

Autor SHA1 Zpráva Datum
  YPZ 81eb0a9e98 Merge remote-tracking branch 'origin/master' před 20 hodinami
  YPZ b439d6735b 修改邮件定时任务 před 20 hodinami
13 změnil soubory, kde provedl 691 přidání a 2 odebrání
  1. 29 2
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/client/MarketEmailsClient.java
  2. 12 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketEmailsController.java
  3. 156 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MailMessageDTO.java
  4. 10 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/SopDetailEntity.java
  5. 16 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketEmailsService.java
  6. 18 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/SopDetailService.java
  7. 77 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerTradeServiceImpl.java
  8. 115 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java
  9. 166 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/SopDetailServiceImpl.java
  10. 33 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/task/SopDetailCountTask.java
  11. 7 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/CustomerVO.java
  12. 29 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsTodayStatsVO.java
  13. 23 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsTrendVO.java

+ 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;
     }
 
     /**

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

@@ -144,6 +144,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 +158,12 @@ public class MarketEmailsController {
 
 
 
+    @GetMapping("/send-trend-7d")
+    @ApiOperation("营销: 最近 7 天发送趋势(按天统计发送成功邮件数,无数据补 0,按日期升序)")
+    public Result<Object> sendTrend7d() {
+        return marketEmailsService.getLast7DaysSendTrend();
+    }
+
     @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;

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

@@ -93,6 +93,22 @@ 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();
+
     /***
       * @Description: 通过id/marketingCampaignId/客户id关联的邮件状态
       * @Param:

+ 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。
      */

+ 115 - 0
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,10 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 
 import javax.annotation.Resource;
+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;
@@ -931,6 +934,118 @@ 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);
+    }
+
+    /**
+     * 分组键转 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;
+        }
+    }
+
     @Override
     public void updateCustomerEmailStatus(MarketEmailsCustomerVO marketEmailsCustomerVO) {
         LambdaQueryWrapper<MarketEmailsEntity> queryWrapper = new LambdaQueryWrapper<>();

+ 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;
 }

+ 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;
+}