Quellcode durchsuchen

Merge remote-tracking branch 'origin/master'

zhujing vor 1 Tag
Ursprung
Commit
7b0706c6d8
21 geänderte Dateien mit 943 neuen und 46 gelöschten Zeilen
  1. 1 0
      java/storlead-api/src/main/resources/application-dev.yml
  2. 1 0
      java/storlead-api/src/main/resources/application-prod.yml
  3. 14 13
      java/storlead-api/src/main/resources/application-test.yml
  4. 1 0
      java/storlead-knowledge/storlead-knowledge-api/src/main/java/com/storlead/knowledge/api/DocumentController.java
  5. 1 0
      java/storlead-knowledge/storlead-knowledge-core/src/main/java/com/storlead/knowledge/config/DifyProperties.java
  6. 45 1
      java/storlead-knowledge/storlead-knowledge-core/src/main/java/com/storlead/knowledge/utils/HttpService.java
  7. 16 0
      java/storlead-mail/storlead-mail-api/src/main/java/com/storlead/mail/webclient/dto/MktMailStatusChangeRemoteDTO.java
  8. 10 0
      java/storlead-mail/storlead-mail-biz/src/main/java/com/storlead/mail/mapper/MktMailStatusMapper.java
  9. 50 0
      java/storlead-mail/storlead-mail-biz/src/main/java/com/storlead/mail/service/impl/MktMailStatusServiceImpl.java
  10. 81 0
      java/storlead-mail/storlead-mail-core/src/main/java/com/storlead/mail/pojo/entity/MktMailStatusEntity.java
  11. 25 0
      java/storlead-mail/storlead-mail-spi/src/main/java/com/storlead/mail/service/MktMailStatusService.java
  12. 5 0
      java/storlead-sasa/storlead-trade/pom.xml
  13. 31 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketEmailsController.java
  14. 4 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketEmailsDTO.java
  15. 19 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MarketEmailsEntity.java
  16. 31 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketEmailsService.java
  17. 381 2
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java
  18. 37 30
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsStatusSyncServiceImpl.java
  19. 75 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsManagementVO.java
  20. 53 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsProtectVO.java
  21. 62 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsReplyVO.java

+ 1 - 0
java/storlead-api/src/main/resources/application-dev.yml

@@ -191,6 +191,7 @@ logging:
 environment: test
 dify:
   base-url: http://47.112.196.2:11434/v1/
+  download-url: http://47.112.196.2:11435
   dataset-api-key: dataset-qfhXMNcjsRSwN5CK6aoWx3hl
   app-keys:
     chat: app-1-qfhXMNcjsRSwN5CK6aoWx3hl  #对话

+ 1 - 0
java/storlead-api/src/main/resources/application-prod.yml

@@ -241,6 +241,7 @@ file:
 
 dify:
   base-url: http://47.112.196.2:11434/v1/
+  download-url: http://47.112.196.2:11435
   dataset-api-key: dataset-qfhXMNcjsRSwN5CK6aoWx3hl
   app-keys:
     chat: app-1-qfhXMNcjsRSwN5CK6aoWx3hl  #对话

+ 14 - 13
java/storlead-api/src/main/resources/application-test.yml

@@ -21,7 +21,7 @@ spring:
     multipart:
       max-file-size: 20MB
       max-request-size: 20MB
-  ## quartz摰𡁏𧒄隞餃𦛚,��鍂�唳旿摨𤘪䲮?
+  ## quartz摰𡁏𧒄隞餃𦛚,��鍂�唳旿摨𤘪䲮嚙�?
   #  quartz:
   #    job-store-type: jdbc
   #json �園𡢿�喟�銝�頧祆揢
@@ -32,7 +32,7 @@ spring:
     proxy-target-class: true
   #�滨蔭freemarker
   freemarker:
-    # 霈曄蔭璅⊥踎�𡒊��?
+    # 霈曄蔭璅⊥踎�𡒊��?
     suffix: .ftl
     # 霈曄蔭��﹝蝐餃�
     content-type: text/html
@@ -44,7 +44,7 @@ spring:
     # 霈曄蔭ftl��辣頝臬�
     template-loader-path:
       - classpath:/templates
-  # 霈曄蔭�蹱���隞嗉楝敺��js,css? #redis �滨蔭
+  # 霈曄蔭�蹱���隞嗉楝敺��js,css嚙�? #redis �滨蔭
   redis:
     host: test1.storlead.com
     port: 59394
@@ -64,13 +64,13 @@ spring:
     exclude: com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure
   datasource:
     dynamic:
-      druid: # �典�druid��㺭嚗𣬚�憭折����澆�暺䁅恕靽脲�銝��氬�?�啣歇�舀�����啣�銝?銝齿�璆𡁜鉄銋劐�閬�僚霈曄蔭)
+      druid: # �典�druid��㺭嚗𣬚�憭折����澆�暺䁅恕靽脲�銝��湛蕭?�啣歇�舀�����啣�嚙�?銝齿�璆𡁜鉄銋劐�閬�僚霈曄蔭)
         # 餈墧𦻖瘙删��滨蔭靽⊥�
-        # �嘥��硋之撠𧶏���撠𧶏���?
+        # �嘥��硋之撠𧶏���撠𧶏���嚙�?
         initial-size: 5
         min-idle: 5
         maxActive: 20
-        # �滨蔭�瑕�餈墧𦻖蝑匧�頞�𧒄��𧒄�?
+        # �滨蔭�瑕�餈墧𦻖蝑匧�頞�𧒄��𧒄�?
         maxWait: 60000
         # �滨蔭�湧�憭帋��滩�銵䔶�甈⊥�瘚页�璉�瘚钅�閬���剔�蝛粹𤦭餈墧𦻖嚗��雿齿糓瘥怎�
         timeBetweenEvictionRunsMillis: 60000
@@ -80,10 +80,10 @@ spring:
         testWhileIdle: true
         testOnBorrow: false
         testOnReturn: false
-        # �枏�PSCache嚗�僎銝娍�摰𡁏�銝芾��乩�PSCache��之?
+        # �枏�PSCache嚗�僎銝娍�摰𡁏�銝芾��乩�PSCache��之嚙�?
         poolPreparedStatements: true
         maxPoolPreparedStatementPerConnectionSize: 20
-        # �滨蔭�烐綉蝏蠘恣�行⏛��ilters嚗�縧�匧��烐綉�屸𢒰sql�䭾�蝏蠘恣嚗?wall'�其��脩�憓?
+        # �滨蔭�烐綉蝏蠘恣�行⏛��ilters嚗�縧�匧��烐綉�屸𢒰sql�䭾�蝏蠘恣嚙�?wall'�其��脩�嚙�?
         filters: stat,wall,slf4j
         # �朞�connectProperties撅墧�扳䔉�枏�mergeSql�蠘�嚗𥟇�SQL霈啣�
         connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
@@ -127,7 +127,7 @@ spring:
 #mybatis plus 霈曄蔭
 mybatis-plus:
   mapper-locations: classpath*:/mapper/*Mapper.xml,classpath*:/mapper/*/*Mapper.xml
-  # 摰硺��急�嚗��銝?package �券�堒噡�𤥁����瑕��?
+  # 摰硺��急�嚗��嚙�?package �券�堒噡�𤥁����瑕�嚙�?
   type-aliases-package: com.storlead.tems.modules.*.entity
   type-enums-package:
     #  configuration:
@@ -138,9 +138,9 @@ mybatis-plus:
     # �喲𡡒MP3.0�芸蒂��anner
     banner: false
     db-config:
-      #銝駁睸蝐餃�  0:"�唳旿摨𨧻D�芸�",1:"霂亦掩�衤蛹�芾挽蝵桐蜓�桃掩�?, 2:"�冽�颲枏�ID",3:"�典��臭�ID (�啣�蝐餃��臭�ID)", 4:"�典��臭�ID UUID",5:"摮㛖泵銝脣�撅��臭�ID (idWorker ���蝚虫葡銵函內)";
+      #銝駁睸蝐餃�  0:"�唳旿摨𨧻D�芸�",1:"霂亦掩�衤蛹�芾挽蝵桐蜓�桃掩�?, 2:"�冽�颲枏�ID",3:"�典��臭�ID (�啣�蝐餃��臭�ID)", 4:"�典��臭�ID UUID",5:"摮㛖泵銝脣�撅��臭�ID (idWorker ���蝚虫葡銵函內)";
       id-type: 4
-      # 暺䁅恕�唳旿摨栞”銝见�蝥踹𦶢�?
+      # 暺䁅恕�唳旿摨栞”銝见�蝥踹𦶢�?
       table-underline: true
     #configuration:
     # 餈嗘葵�滨蔭隡𡁜��扯���ql�枏㫲�箸䔉嚗�銁撘��烐�瘚贝���𧒄�坔虾隞亦鍂
@@ -176,6 +176,7 @@ logging:
 environment: test
 dify:
   base-url: http://47.112.196.2:11434/v1/
+  download-url: http://47.112.196.2:11435
   dataset-api-key: dataset-qfhXMNcjsRSwN5CK6aoWx3hl
   app-keys:
     chat: app-1-qfhXMNcjsRSwN5CK6aoWx3hl  #撖寡�
@@ -190,8 +191,8 @@ storlead:
   mail:
     remote:
       enabled: true
-      scheme: http
-      host: 127.0.0.1
+      scheme: https
+      host: email.test.storlead.com
       port: 18090
       context-path: /router/rest
       token-header: token

+ 1 - 0
java/storlead-knowledge/storlead-knowledge-api/src/main/java/com/storlead/knowledge/api/DocumentController.java

@@ -154,6 +154,7 @@ public class DocumentController {
         String url = difyProperties.getBaseUrl() + "datasets/"+dataset_id + "/documents/"+ document_id+"/download";
         return httpService.get(
                 url,
+                difyProperties.getDownloadUrl(),
                 null,
                 "Bearer "+difyProperties.getDatasetApiKey(),
                 new TypeReference<>() {});

+ 1 - 0
java/storlead-knowledge/storlead-knowledge-core/src/main/java/com/storlead/knowledge/config/DifyProperties.java

@@ -14,6 +14,7 @@ import java.util.Map;
 public class DifyProperties {
 
     private String baseUrl;
+    private String downloadUrl;
     private String datasetApiKey;
     private Map<String, String> appKeys;
 

+ 45 - 1
java/storlead-knowledge/storlead-knowledge-core/src/main/java/com/storlead/knowledge/utils/HttpService.java

@@ -102,6 +102,50 @@ public class HttpService {
         }
     }
 
+    public <T> Result<T> get(
+            String url,
+            String downloadUrl,
+            Map<String, ?> queryParams,
+            String authorization,
+            TypeReference<T> typeReference
+    ) {
+        try {
+            String fullUrl = UrlBuilder.build(url, queryParams);
+            HttpRequest request = baseRequest(authorization)
+                    .uri(URI.create(fullUrl))
+                    .GET()
+                    .build();
+
+            HttpResponse<String> response =
+                    httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+            return handleResponse(response,
+                    body -> {
+                        try {
+                            ObjectMapper mapper = new ObjectMapper();
+                            JsonNode rootNode = mapper.readTree(body);
+
+                            // 强制转为 ObjectNode 才能修改
+                            ObjectNode objectNode = (ObjectNode) rootNode;
+
+                            // 修改 url 的值
+                            String newUrl = downloadUrl + objectNode.get("url").asText();
+                            objectNode.put("url", newUrl);
+
+                            // 转回字符串
+                            String result = mapper.writeValueAsString(objectNode);
+
+                            return objectMapper.readValue(result, typeReference);
+                        } catch (JsonProcessingException e) {
+                            throw new RuntimeException(e);
+                        }
+                    });
+        } catch (Exception e) {
+            return (Result<T>) Result.error(e.getMessage());
+        }
+    }
+
+
     /* ================= POST ================= */
 
     public <T> Result<T> post(
@@ -363,7 +407,7 @@ public class HttpService {
 
         String CRLF = "\r\n";
         String prefix = "--" + boundary + CRLF;
-        String suffix = CRLF + "--" + boundary + "--";
+        String suffix = CRLF + "--" + boundary + "--";  
 
         ByteArrayOutputStream out = new ByteArrayOutputStream();
 

+ 16 - 0
java/storlead-mail/storlead-mail-api/src/main/java/com/storlead/mail/webclient/dto/MktMailStatusChangeRemoteDTO.java

@@ -40,4 +40,20 @@ public class MktMailStatusChangeRemoteDTO {
     @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @ApiModelProperty("同步时间标记;同步成功后由 SaaS 回写刷新")
     private LocalDateTime statusUpdatedAt;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ApiModelProperty("首次打开时间(B 侧 mkt_mail_status 维护)")
+    private LocalDateTime firstOpenTime;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ApiModelProperty("首次回复时间(B 侧 mkt_mail_status 维护)")
+    private LocalDateTime firstReplyTime;
+
+    @ApiModelProperty("首次回复邮件id(B 侧 mkt_mail_status 维护,对应邮件库回复邮件主键)")
+    private Long firstReplyEmailId;
+
+    @ApiModelProperty("回复邮件主题(B 侧 mkt_mail_status 维护)")
+    private String firstReplyTheme;
 }

+ 10 - 0
java/storlead-mail/storlead-mail-biz/src/main/java/com/storlead/mail/mapper/MktMailStatusMapper.java

@@ -0,0 +1,10 @@
+package com.storlead.mail.mapper;
+
+import com.storlead.mail.mapper.support.MailBaseMapper;
+import com.storlead.mail.pojo.entity.MktMailStatusEntity;
+
+/**
+ * 营销邮件状态 Mapper(邮件库)。
+ */
+public interface MktMailStatusMapper extends MailBaseMapper<MktMailStatusEntity> {
+}

+ 50 - 0
java/storlead-mail/storlead-mail-biz/src/main/java/com/storlead/mail/service/impl/MktMailStatusServiceImpl.java

@@ -0,0 +1,50 @@
+package com.storlead.mail.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.mail.mapper.MktMailStatusMapper;
+import com.storlead.mail.pojo.entity.MktMailStatusEntity;
+import com.storlead.mail.service.MktMailStatusService;
+import com.storlead.mail.service.impl.support.MailDataSourceServiceImpl;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 邮件库 mkt_mail_status 读写(@DS mail)。
+ */
+@Service
+public class MktMailStatusServiceImpl
+        extends MailDataSourceServiceImpl<MktMailStatusMapper, MktMailStatusEntity>
+        implements MktMailStatusService {
+
+    @Override
+    public List<MktMailStatusEntity> listPendingChanges(Long lastId, int limit) {
+        if (limit <= 0) {
+            return Collections.emptyList();
+        }
+        LambdaQueryWrapper<MktMailStatusEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MktMailStatusEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .apply("update_time > status_updated_at")
+                .gt(lastId != null, MktMailStatusEntity::getId, lastId)
+                .orderByAsc(MktMailStatusEntity::getId)
+                .last("limit " + limit);
+        return list(wrapper);
+    }
+
+    @Override
+    public boolean ackStatusSync(List<Long> mailIds) {
+        if (CollectionUtils.isEmpty(mailIds)) {
+            return true;
+        }
+        LambdaUpdateWrapper<MktMailStatusEntity> update = new LambdaUpdateWrapper<>();
+        update.in(MktMailStatusEntity::getMailId, mailIds)
+                .eq(MktMailStatusEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .set(MktMailStatusEntity::getStatusUpdatedAt, new Date());
+        return update(update);
+    }
+}

+ 81 - 0
java/storlead-mail/storlead-mail-core/src/main/java/com/storlead/mail/pojo/entity/MktMailStatusEntity.java

@@ -0,0 +1,81 @@
+package com.storlead.mail.pojo.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.storlead.framework.mybatis.entity.SysBaseField;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.util.Date;
+
+/**
+ * 营销邮件状态(邮件库 mkt_mail_status,供业务库增量同步)。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Accessors(chain = true)
+@TableName("mkt_mail_status")
+@ApiModel(value = "MktMailStatusEntity", description = "营销邮件状态")
+public class MktMailStatusEntity extends SysBaseField {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("邮件ID,对应 mkt_mail_message.id / market_emails.email_id")
+    @TableField("mail_id")
+    private Long mailId;
+
+    @ApiModelProperty(value = "首次回复邮件id")
+    @TableField("first_reply_email_id")
+    private Long firstReplyEmailId;
+
+    @ApiModelProperty(value = "首次回复邮件主题")
+    @TableField("first_reply_theme")
+    private String firstReplyTheme;
+
+    @ApiModelProperty("发送状态:-2草稿 -1定时 0发送中 1成功 2失败")
+    @TableField("send_status")
+    private Integer sendStatus;
+
+    @ApiModelProperty("是否已打开(像素):0否 1是")
+    @TableField("opened")
+    private Integer opened;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("first_open_time")
+    private Date firstOpenTime;
+
+    @ApiModelProperty("是否已读:0否 1是")
+    @TableField("is_read")
+    private Integer isRead;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("first_read_time")
+    private Date firstReadTime;
+
+    @ApiModelProperty("是否已回复:0否 1是")
+    @TableField("replied")
+    private Integer replied;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("first_reply_time")
+    private Date firstReplyTime;
+
+    @ApiModelProperty("状态最后更新时间(第三方增量游标)")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("status_updated_at")
+    private Date statusUpdatedAt;
+}

+ 25 - 0
java/storlead-mail/storlead-mail-spi/src/main/java/com/storlead/mail/service/MktMailStatusService.java

@@ -0,0 +1,25 @@
+package com.storlead.mail.service;
+
+import com.storlead.framework.mybatis.service.MyBaseService;
+import com.storlead.mail.pojo.entity.MktMailStatusEntity;
+
+import java.util.List;
+
+/**
+ * 营销邮件状态(邮件库 mkt_mail_status)。
+ */
+public interface MktMailStatusService extends MyBaseService<MktMailStatusEntity> {
+
+    /**
+     * 待同步:update_time &gt; status_updated_at,按 id 升序分页。
+     *
+     * @param lastId 上一批最后一条 id,首页传 null
+     * @param limit  批量大小
+     */
+    List<MktMailStatusEntity> listPendingChanges(Long lastId, int limit);
+
+    /**
+     * 同步成功后回写 status_updated_at = now()。
+     */
+    boolean ackStatusSync(List<Long> mailIds);
+}

+ 5 - 0
java/storlead-sasa/storlead-trade/pom.xml

@@ -72,6 +72,11 @@
             <groupId>com.storlead.boot</groupId>
             <artifactId>storlead-mail-spi</artifactId>
         </dependency>
+        <!-- 跨库读 mkt_mail_status:Mapper/Service 实现在 mail-biz(@DS mail) -->
+        <dependency>
+            <groupId>com.storlead.boot</groupId>
+            <artifactId>storlead-mail-biz</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.storlead.boot</groupId>
             <artifactId>storlead-knowledge-core</artifactId>

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

@@ -3,6 +3,7 @@ package com.storlead.trade.controller;
 import com.storlead.trade.dto.MarketEmailsDTO;
 import com.storlead.trade.service.MarketEmailsService;
 import com.storlead.framework.common.result.Result;
+import com.storlead.trade.vo.MarketEmailsManagementVO;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -50,6 +51,30 @@ public class MarketEmailsController {
         return marketEmailsService.suspend(marketEmailsDTO);
     }
 
+    @PostMapping("/cancel")
+    @ApiOperation("营销: 取消发送邮件(按 id 或 idList,status 置 -2)")
+    public Result<Object> cancel(@RequestBody MarketEmailsDTO marketEmailsDTO) {
+        return marketEmailsService.cancel(marketEmailsDTO);
+    }
+
+    @GetMapping("/reply-list")
+    @ApiOperation("营销: 回复邮件的客户列表(按 marketingCampaignId)")
+    public Result<Object> replyList(Long id) {
+        return marketEmailsService.getReplyList(id);
+    }
+
+    @GetMapping("/intervention-list")
+    @ApiOperation("营销: 人工干预列表(客户保护白名单/营销暂停)")
+    public Result<Object> interventionList(Long id, String tabType) {
+        return marketEmailsService.getInterventionList(id, tabType);
+    }
+
+    @PostMapping("/remove-protect")
+    @ApiOperation("营销: 移除保护(按邮件主键 id,has_protect 置 0)")
+    public Result<Object> removeProtect(@RequestBody MarketEmailsDTO marketEmailsDTO) {
+        return marketEmailsService.removeProtect(marketEmailsDTO.getId());
+    }
+
     @PostMapping("/add")
     @ApiOperation("营销: 营销邮件添加")
     public Result<?> add(@RequestBody MarketEmailsDTO marketEmailsDTO) {
@@ -88,4 +113,10 @@ public class MarketEmailsController {
         }
         return Result.result(marketEmailsService.getById(id));
     }
+
+    @GetMapping("/management-detail")
+    @ApiOperation("营销: 邮件管理弹窗(按 marketingCampaignId 查询所有邮件卡片)")
+    public Result<Object> managementDetail(Long id) {
+        return marketEmailsService.getManagementDetail(id);
+    }
 }

+ 4 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketEmailsDTO.java

@@ -61,6 +61,10 @@ public class MarketEmailsDTO extends Page {
     @TableField("has_suspend")
     private Integer hasSuspend;
 
+    @ApiModelProperty(value = "保护原因(仅添加/更新保护时使用)")
+    @TableField("protect_reason")
+    private String protectReason;
+
     @ApiModelProperty(value = "发件人邮箱")
     @TableField("recipient")
     private String recipient;

+ 19 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MarketEmailsEntity.java

@@ -77,4 +77,23 @@ public class MarketEmailsEntity extends SysBaseField {
     @TableField("first_open_time")
     private Date firstOpenTime;
 
+    @ApiModelProperty(value = "首次回复时间")
+    @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")
+    @TableField("first_reply_time")
+    private Date firstReplyTime;
+
+    @ApiModelProperty(value = "首次回复邮件id")
+    @TableField("first_reply_email_id")
+    private Long firstReplyEmailId;
+
+    @ApiModelProperty(value = "首次回复邮件主题")
+    @TableField("first_reply_theme")
+    private String firstReplyTheme;
+
+    @ApiModelProperty(value = "保护原因")
+    @TableField("protect_reason")
+    private String protectReason;
+
 }

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

@@ -4,6 +4,7 @@ import com.storlead.trade.dto.MarketEmailsDTO;
 import com.storlead.trade.entity.MarketEmailsEntity;
 import com.storlead.trade.entity.MarketingCampaignEntity;
 import com.storlead.trade.vo.MarketEmailsCustomerVO;
+import com.storlead.trade.vo.MarketEmailsManagementVO;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.MyBaseService;
 import org.springframework.scheduling.annotation.Async;
@@ -34,6 +35,36 @@ public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
      */
     Result<Object> suspend(MarketEmailsDTO marketEmailsDTO);
 
+    /**
+     * 取消发送邮件(按 id 或 idList,status 置 -2;软取消,不影响 isDelete)
+     */
+    Result<Object> cancel(MarketEmailsDTO marketEmailsDTO);
+
+    /**
+     * 回复邮件的客户列表(按 marketingCampaignId 过滤 + hasReply=1 + recipient 聚合)
+     * 不分页(页面 Tab 全部/已保护/营销暂停 展示)
+     */
+    Result<Object> getReplyList(Long marketingCampaignId);
+
+    /**
+     * 人工干预列表(客户保护白名单 / 营销暂停,按 marketingCampaignId + tabType 过滤)
+     * tabType: protect(保护白名单) / suspend(营销暂停)
+     */
+    Result<Object> getInterventionList(Long marketingCampaignId, String tabType);
+
+    /**
+     * 移除保护(按邮件主键 id 把 has_protect 置 0)
+     */
+    Result<Object> removeProtect(Long id);
+
+    /**
+     * 邮件管理弹窗(按营销活动 id 查询,拼装一个营销活动下所有邮件卡片)
+     *
+     * @param marketingCampaignId 营销活动 id(MarketingCampaignEntity.id)
+     * @return List<VO>,每个元素一张卡片(含 customerName / stageName / recipient / expectSendTime / status)
+     */
+    Result<Object> getManagementDetail(Long marketingCampaignId);
+
     @Async
     void addSendTime(MarketingCampaignEntity marketingCampaignEntity);
 

+ 381 - 2
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java

@@ -21,6 +21,7 @@ import com.storlead.trade.entity.*;
 import com.storlead.trade.mapper.MarketEmailsMapper;
 import com.storlead.trade.service.*;
 import com.storlead.trade.vo.MarketEmailsCustomerVO;
+import com.storlead.trade.vo.MarketEmailsManagementVO;
 import com.storlead.trade.vo.MarketEmailsOpenVO;
 import com.storlead.trade.vo.ScheduleStageVO;
 import org.slf4j.Logger;
@@ -64,6 +65,10 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
     @Resource
     private WorkflowsService workflowsService;
 
+    @Resource
+    @org.springframework.context.annotation.Lazy
+    private MarketingCampaignService marketingCampaignService;
+
 
     @Override
     public Result<Object> getList(MarketEmailsDTO dto) {
@@ -228,11 +233,11 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
             }
             if (!customerIdSet.isEmpty()) {
                 try {
-                    java.util.List<com.storlead.trade.entity.CustomerEntity> customers =
+                    java.util.List<CustomerEntity> customers =
                             customerTradeService.listByIds(new java.util.ArrayList<>(customerIdSet));
                     if (!CollectionUtils.isEmpty(customers)) {
                         java.util.Map<Long, String> idToName = new java.util.HashMap<>(customers.size());
-                        for (com.storlead.trade.entity.CustomerEntity c : customers) {
+                        for (CustomerEntity c : customers) {
                             if (c.getId() != null) {
                                 idToName.put(c.getId(), c.getCustomerName());
                             }
@@ -271,6 +276,14 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         wrapper.in(MarketEmailsEntity::getId, ids);
         wrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
         wrapper.set(MarketEmailsEntity::getHasProtect, dto.getHasProtect());
+        // 如果是开启保护 且 前端传了保护原因 -> 一并写入
+        if (Integer.valueOf(1).equals(dto.getHasProtect()) && !ObjectUtils.isEmpty(dto.getProtectReason())) {
+            wrapper.set(MarketEmailsEntity::getProtectReason, dto.getProtectReason());
+        }
+        // 如果是移除保护 -> 清空保护原因
+        if (Integer.valueOf(0).equals(dto.getHasProtect())) {
+            wrapper.set(MarketEmailsEntity::getProtectReason, null);
+        }
         boolean ok = this.update(wrapper);
         if (!ok) {
             return Result.error("保护失败");
@@ -315,6 +328,372 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         return Result.ok();
     }
 
+    /**
+     * 取消发送邮件:status 置 -2(软取消,不删数据,isDelete 保持 0)
+     * 支持单条(dto.id)或批量(dto.idList)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Result<Object> cancel(MarketEmailsDTO dto) {
+        if (dto == null) {
+            return Result.error("请求体不能为空");
+        }
+        List<Long> ids = new ArrayList<>();
+        if (!ObjectUtils.isEmpty(dto.getId())) {
+            ids.add(dto.getId());
+        }
+        if (!CollectionUtils.isEmpty(dto.getIdList())) {
+            ids.addAll(dto.getIdList());
+        }
+        if (ids.isEmpty()) {
+            return Result.error("id 或 idList 不能为空");
+        }
+
+        LambdaUpdateWrapper<MarketEmailsEntity> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.in(MarketEmailsEntity::getId, ids);
+        wrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        // status = -2 表示取消发送
+        wrapper.set(MarketEmailsEntity::getStatus, -2);
+        boolean ok = this.update(wrapper);
+        if (!ok) {
+            return Result.error("取消发送失败");
+        }
+        return Result.ok();
+    }
+
+    /**
+     * 回复邮件的客户列表(按 marketingCampaignId 过滤 + hasReply=1 + recipient 聚合)
+     * 不分页:全部/已保护/营销暂停 三个 Tab 由前端在结果中过滤计数。
+     */
+    @Override
+    public Result<Object> getReplyList(Long marketingCampaignId) {
+        if (ObjectUtils.isEmpty(marketingCampaignId)) {
+            return Result.error("marketingCampaignId不能为空");
+        }
+        // 查该营销活动下 has_reply=1 且未软删的邮件
+        LambdaQueryWrapper<MarketEmailsEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        wrapper.eq(MarketEmailsEntity::getHasReply, 1);
+        wrapper.eq(MarketEmailsEntity::getMarketingCampaignId, marketingCampaignId);
+        wrapper.isNotNull(MarketEmailsEntity::getRecipient);
+        wrapper.ne(MarketEmailsEntity::getRecipient, "");
+        wrapper.select(MarketEmailsEntity::getRecipient,
+                MarketEmailsEntity::getCustomerId,
+                MarketEmailsEntity::getHasProtect,
+                MarketEmailsEntity::getHasSuspend,
+                MarketEmailsEntity::getFirstReplyTime,
+                MarketEmailsEntity::getFirstReplyEmailId,
+                MarketEmailsEntity::getFirstReplyTheme,
+                MarketEmailsEntity::getCreateTime);
+        List<MarketEmailsEntity> all = this.list(wrapper);
+
+        // 按 recipient 聚合
+        Map<String, com.storlead.trade.vo.MarketEmailsReplyVO> aggMap = new HashMap<>();
+        if (!CollectionUtils.isEmpty(all)) {
+            for (MarketEmailsEntity e : all) {
+                String r = e.getRecipient();
+                if (r == null || r.isEmpty()) {
+                    continue;
+                }
+                com.storlead.trade.vo.MarketEmailsReplyVO vo = aggMap.computeIfAbsent(r, k -> {
+                    com.storlead.trade.vo.MarketEmailsReplyVO v = new com.storlead.trade.vo.MarketEmailsReplyVO();
+                    v.setRecipient(k);
+                    v.setReplyCount(0L);
+                    return v;
+                });
+                vo.setReplyCount(vo.getReplyCount() + 1);
+                // 记下最早一条所属的 customerId(用于回填客户名)
+                if (vo.getCustomerId() == null && e.getCustomerId() != null) {
+                    vo.setCustomerId(e.getCustomerId());
+                }
+                // 取最早的 firstReplyTime + 同时记录主题/emailId
+                if (e.getFirstReplyTime() != null) {
+                    if (vo.getFirstReplyTime() == null || e.getFirstReplyTime().before(vo.getFirstReplyTime())) {
+                        vo.setFirstReplyTime(e.getFirstReplyTime());
+                        vo.setReplyContent(e.getFirstReplyTheme());
+                    }
+                }
+                // MAX(has_protect) / MAX(has_suspend) 语义
+                if (e.getHasProtect() != null && e.getHasProtect() == 1) {
+                    vo.setHasProtect(1);
+                }
+                if (e.getHasSuspend() != null && e.getHasSuspend() == 1) {
+                    vo.setHasSuspend(1);
+                }
+            }
+        }
+        // 默认未保护/未暂停为 0
+        for (com.storlead.trade.vo.MarketEmailsReplyVO vo : aggMap.values()) {
+            if (vo.getHasProtect() == null) vo.setHasProtect(0);
+            if (vo.getHasSuspend() == null) vo.setHasSuspend(0);
+        }
+        // 按首次回复时间倒序(最近回复的排前)
+        List<com.storlead.trade.vo.MarketEmailsReplyVO> voList = new java.util.ArrayList<>(aggMap.values());
+        voList.sort((a, b) -> {
+            if (a.getFirstReplyTime() == null && b.getFirstReplyTime() == null) return 0;
+            if (a.getFirstReplyTime() == null) return 1;
+            if (b.getFirstReplyTime() == null) return -1;
+            return b.getFirstReplyTime().compareTo(a.getFirstReplyTime());
+        });
+
+        // 批量回填:客户名 / 客户等级 / 核心标签
+        if (!voList.isEmpty()) {
+            java.util.Set<Long> customerIdSet = new java.util.LinkedHashSet<>();
+            for (com.storlead.trade.vo.MarketEmailsReplyVO v : voList) {
+                if (v.getCustomerId() != null) customerIdSet.add(v.getCustomerId());
+            }
+            if (!customerIdSet.isEmpty()) {
+                try {
+                    // 回填客户名
+                    java.util.List<CustomerEntity> customers =
+                            customerTradeService.listByIds(new java.util.ArrayList<>(customerIdSet));
+                    java.util.Map<Long, CustomerEntity> customerMap = new java.util.HashMap<>();
+                    if (!CollectionUtils.isEmpty(customers)) {
+                        for (CustomerEntity c : customers) {
+                            if (c.getId() != null) customerMap.put(c.getId(), c);
+                        }
+                    }
+                    // 回填客户等级 / 核心标签(从 CustomerAnalysisResultEntity)
+                    // 一个 customer 可能有多个分析结果,取最新一条(id 最大)
+                    java.util.List<CustomerAnalysisResultEntity> analysisList =
+                            customerAnalysisResultEntityService.list(
+                                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CustomerAnalysisResultEntity>()
+                                            .in(CustomerAnalysisResultEntity::getCustomerId, customerIdSet)
+                                            .eq(CustomerAnalysisResultEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                                            .orderByDesc(CustomerAnalysisResultEntity::getId));
+                    java.util.Map<Long, CustomerAnalysisResultEntity> analysisMap = new java.util.HashMap<>();
+                    if (!CollectionUtils.isEmpty(analysisList)) {
+                        for (CustomerAnalysisResultEntity a : analysisList) {
+                            if (a.getCustomerId() != null && !analysisMap.containsKey(a.getCustomerId())) {
+                                analysisMap.put(a.getCustomerId(), a);
+                            }
+                        }
+                    }
+                    for (com.storlead.trade.vo.MarketEmailsReplyVO v : voList) {
+                        CustomerEntity c = customerMap.get(v.getCustomerId());
+                        if (c != null) {
+                            v.setCustomerName(c.getCustomerName());
+                        }
+                        CustomerAnalysisResultEntity a = analysisMap.get(v.getCustomerId());
+                        if (a != null) {
+                            v.setCustomerLevel(a.getCustomerLevel());
+                            v.setCoreTags(a.getCoreTags());
+                            if (a.getCustomerLevel() != null) {
+                                v.setCustomerLevelLabel(a.getCustomerLevel());
+                            }
+                        }
+                    }
+                } catch (Exception ex) {
+                    log.warn("回填客户名/标签失败,customerIds={} err={}", customerIdSet, ex.getMessage());
+                }
+            }
+        }
+        return Result.ok(voList);
+    }
+
+    /**
+     * 人工干预列表:tabType = protect / suspend
+     * 策略:单活动下保护/暂停的邮件数通常几百~几千,先查全集在内存里按 recipient 分组
+     */
+    @Override
+    public Result<Object> getInterventionList(Long marketingCampaignId, String tabType) {
+        if (ObjectUtils.isEmpty(marketingCampaignId)) {
+            return Result.error("marketingCampaignId不能为空");
+        }
+        if (!"protect".equalsIgnoreCase(tabType) && !"suspend".equalsIgnoreCase(tabType)) {
+            return Result.error("tabType 必须是 protect 或 suspend");
+        }
+
+        LambdaQueryWrapper<MarketEmailsEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        wrapper.eq(MarketEmailsEntity::getMarketingCampaignId, marketingCampaignId);
+        wrapper.isNotNull(MarketEmailsEntity::getRecipient);
+        wrapper.ne(MarketEmailsEntity::getRecipient, "");
+        if ("protect".equalsIgnoreCase(tabType)) {
+            wrapper.eq(MarketEmailsEntity::getHasProtect, 1);
+        } else {
+            wrapper.eq(MarketEmailsEntity::getHasSuspend, 1);
+        }
+        wrapper.select(MarketEmailsEntity::getId,
+                MarketEmailsEntity::getRecipient,
+                MarketEmailsEntity::getCustomerId,
+                MarketEmailsEntity::getHasProtect,
+                MarketEmailsEntity::getHasSuspend,
+                MarketEmailsEntity::getProtectReason,
+                MarketEmailsEntity::getUpdateTime);
+        List<MarketEmailsEntity> all = this.list(wrapper);
+
+        // 按 recipient 聚合(同一客户多个保护/暂停记录合并为一张卡片)
+        Map<String, com.storlead.trade.vo.MarketEmailsProtectVO> aggMap = new HashMap<>();
+        if (!CollectionUtils.isEmpty(all)) {
+            for (MarketEmailsEntity e : all) {
+                String r = e.getRecipient();
+                if (r == null || r.isEmpty()) continue;
+                com.storlead.trade.vo.MarketEmailsProtectVO vo = aggMap.computeIfAbsent(r, k -> {
+                    com.storlead.trade.vo.MarketEmailsProtectVO v = new com.storlead.trade.vo.MarketEmailsProtectVO();
+                    v.setRecipient(k);
+                    v.setHasProtect(0);
+                    v.setHasSuspend(0);
+                    return v;
+                });
+                // 记下第一条(最早设置保护/暂停的那一条)
+                if (vo.getId() == null) {
+                    vo.setId(e.getId());
+                }
+                if (vo.getCustomerId() == null && e.getCustomerId() != null) {
+                    vo.setCustomerId(e.getCustomerId());
+                }
+                if (e.getHasProtect() != null && e.getHasProtect() == 1) {
+                    vo.setHasProtect(1);
+                }
+                if (e.getHasSuspend() != null && e.getHasSuspend() == 1) {
+                    vo.setHasSuspend(1);
+                }
+                // 取最早的 updateTime 作为保护/暂停时间
+                if (e.getUpdateTime() != null) {
+                    if (vo.getProtectTime() == null || e.getUpdateTime().before(vo.getProtectTime())) {
+                        vo.setProtectTime(e.getUpdateTime());
+                    }
+                }
+                // 保护原因:取该 recipient 下任意一条非空的(多条一致时取最先写入的)
+                if (vo.getProtectReason() == null && e.getProtectReason() != null && !e.getProtectReason().isEmpty()) {
+                    vo.setProtectReason(e.getProtectReason());
+                }
+            }
+        }
+        // 保护时间 DESC 排序
+        List<com.storlead.trade.vo.MarketEmailsProtectVO> voList = new java.util.ArrayList<>(aggMap.values());
+        voList.sort((a, b) -> {
+            if (a.getProtectTime() == null && b.getProtectTime() == null) return 0;
+            if (a.getProtectTime() == null) return 1;
+            if (b.getProtectTime() == null) return -1;
+            return b.getProtectTime().compareTo(a.getProtectTime());
+        });
+
+        // 批量回填客户名
+        if (!voList.isEmpty()) {
+            java.util.Set<Long> customerIdSet = new java.util.LinkedHashSet<>();
+            for (com.storlead.trade.vo.MarketEmailsProtectVO v : voList) {
+                if (v.getCustomerId() != null) customerIdSet.add(v.getCustomerId());
+            }
+            if (!customerIdSet.isEmpty()) {
+                try {
+                    java.util.List<com.storlead.trade.entity.CustomerEntity> customers =
+                            customerTradeService.listByIds(new java.util.ArrayList<>(customerIdSet));
+                    java.util.Map<Long, com.storlead.trade.entity.CustomerEntity> customerMap = new java.util.HashMap<>();
+                    if (!CollectionUtils.isEmpty(customers)) {
+                        for (com.storlead.trade.entity.CustomerEntity c : customers) {
+                            if (c.getId() != null) customerMap.put(c.getId(), c);
+                        }
+                    }
+                    for (com.storlead.trade.vo.MarketEmailsProtectVO v : voList) {
+                        com.storlead.trade.entity.CustomerEntity c = customerMap.get(v.getCustomerId());
+                        if (c != null) {
+                            v.setCustomerName(c.getCustomerName());
+                        }
+                    }
+                } catch (Exception ex) {
+                    log.warn("回填客户名失败,customerIds={} err={}", customerIdSet, ex.getMessage());
+                }
+            }
+        }
+        return Result.ok(voList);
+    }
+
+    /**
+     * 移除保护:按邮件主键 id 把 has_protect 置 0(保留 has_suspend)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Result<Object> removeProtect(Long id) {
+        if (ObjectUtils.isEmpty(id)) {
+            return Result.error("id不能为空");
+        }
+        MarketEmailsEntity exist = this.getById(id);
+        if (exist == null || CommonConstant.DEL_FLAG_1.equals(exist.getIsDelete())) {
+            return Result.error("邮件不存在或已删除");
+        }
+        LambdaUpdateWrapper<MarketEmailsEntity> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(MarketEmailsEntity::getId, id);
+        wrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        wrapper.set(MarketEmailsEntity::getHasProtect, 0);
+        // 同步清空保护原因
+        wrapper.set(MarketEmailsEntity::getProtectReason, null);
+        boolean ok = this.update(wrapper);
+        if (!ok) {
+            return Result.error("移除保护失败");
+        }
+        return Result.ok();
+    }
+
+
+    @Override
+    public Result<Object> getManagementDetail(Long marketingCampaignId) {
+        if (ObjectUtils.isEmpty(marketingCampaignId)) {
+            return Result.error("marketingCampaignId不能为空");
+        }
+
+        // 营销活动名(用于拼接在卡片标题位置或返回顶部)
+        String campaignName = null;
+        MarketingCampaignEntity campaign = marketingCampaignService.getById(marketingCampaignId);
+        if (campaign != null) {
+            campaignName = campaign.getName();
+        }
+
+        // 查该营销活动下所有未软删的邮件
+        LambdaQueryWrapper<MarketEmailsEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MarketEmailsEntity::getMarketingCampaignId, marketingCampaignId);
+        wrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        wrapper.orderByAsc(MarketEmailsEntity::getExpectSendTime);
+        List<MarketEmailsEntity> entityList = this.list(wrapper);
+        if (CollectionUtils.isEmpty(entityList)) {
+            return Result.ok(new ArrayList<MarketEmailsManagementVO>());
+        }
+
+        // 批量收 ID,减少 N+1
+        java.util.Set<Long> customerIdSet = new java.util.HashSet<>();
+        java.util.Set<Long> sopDetailIdSet = new java.util.HashSet<>();
+        for (MarketEmailsEntity e : entityList) {
+            if (e.getCustomerId() != null) customerIdSet.add(e.getCustomerId());
+            if (e.getMarketingCampaignSopDetailId() != null) sopDetailIdSet.add(e.getMarketingCampaignSopDetailId());
+        }
+
+        Map<Long, CustomerEntity> customerMap = customerIdSet.isEmpty()
+                ? Collections.emptyMap()
+                : customerTradeService.listByIds(new java.util.ArrayList<>(customerIdSet)).stream()
+                    .collect(Collectors.toMap(CustomerEntity::getId, c -> c, (a, c) -> a));
+        Map<Long, MarketingCampaignSopDetailEntity> sopDetailMap = sopDetailIdSet.isEmpty()
+                ? Collections.emptyMap()
+                : marketingCampaignSopDetailEntityService.listByIds(new java.util.ArrayList<>(sopDetailIdSet)).stream()
+                    .collect(Collectors.toMap(MarketingCampaignSopDetailEntity::getId, s -> s, (a, c) -> a));
+
+        // 组装 VO
+        List<MarketEmailsManagementVO> voList = new ArrayList<>(entityList.size());
+        for (MarketEmailsEntity entity : entityList) {
+            MarketEmailsManagementVO vo = new MarketEmailsManagementVO();
+            BeanUtils.copyProperties(entity, vo);
+            // expectSendTime 是 DateTime,VO 是 Date,手动 set(BeanUtils 类型不一致不复制)
+            if (entity.getExpectSendTime() != null) {
+                vo.setExpectSendTime(entity.getExpectSendTime());
+            }
+            vo.setCampaignName(campaignName);
+
+            CustomerEntity customer = customerMap.get(entity.getCustomerId());
+            if (customer != null) {
+                vo.setCustomerName(customer.getCustomerName());
+                vo.setCustomerContact(customer.getCollaborator());
+            }
+
+            MarketingCampaignSopDetailEntity sopDetail = sopDetailMap.get(entity.getMarketingCampaignSopDetailId());
+            if (sopDetail != null) {
+                vo.setStageName(sopDetail.getName());
+            }
+
+            voList.add(vo);
+        }
+        return Result.ok(voList);
+    }
+
 
     @Override
     public void addSendTime(MarketingCampaignEntity marketingCampaignEntity) {

+ 37 - 30
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsStatusSyncServiceImpl.java

@@ -5,10 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 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.webclient.client.MktMailRemoteClient;
-import com.storlead.mail.webclient.dto.MktMailStatusChangeRemoteDTO;
-import com.storlead.mail.webclient.dto.MktMailStatusSyncQueryRemoteDTO;
+import com.storlead.mail.pojo.entity.MktMailStatusEntity;
+import com.storlead.mail.service.MktMailStatusService;
 import com.storlead.trade.entity.MarketEmailsEntity;
 import com.storlead.trade.service.MarketEmailsService;
 import com.storlead.trade.service.MarketEmailsStatusSyncService;
@@ -21,8 +19,9 @@ import java.util.ArrayList;
 import java.util.List;
 
 /**
- * mkt_mail_status → market_emails:
- * 查待同步(update_time &gt; status_updated_at)→ 按 mail_id 改业务表 → 回写同步时间。
+ * 跨库同步:邮件库 mkt_mail_status → 业务库 market_emails,再回写 status_updated_at。
+ * <p>
+ * 邮件侧走 {@link MktMailStatusService}(@DS mail),业务侧走 trade 数据源;不做跨库事务。
  */
 @Slf4j
 @Service
@@ -37,7 +36,7 @@ public class MarketEmailsStatusSyncServiceImpl implements MarketEmailsStatusSync
     private static final int TRADE_STATUS_FAIL = 2;
 
     @Resource
-    private MktMailRemoteClient mktMailRemoteClient;
+    private MktMailStatusService mktMailStatusService;
 
     @Resource
     private MarketEmailsService marketEmailsService;
@@ -48,56 +47,44 @@ public class MarketEmailsStatusSyncServiceImpl implements MarketEmailsStatusSync
         Long lastId = null;
 
         for (int page = 0; page < MAX_PAGES; page++) {
-            // 1. 查 mkt_mail_status:有最新修改(update_time > status_updated_at)
-            MktMailStatusSyncQueryRemoteDTO query = new MktMailStatusSyncQueryRemoteDTO();
-            query.setLimit(BATCH_LIMIT);
-            query.setLastId(lastId);
-
-            Result<List<MktMailStatusChangeRemoteDTO>> result;
+            // 1. 邮件库:update_time > status_updated_at
+            List<MktMailStatusEntity> changes;
             try {
-                result = mktMailRemoteClient.statusChanges(query);
+                changes = mktMailStatusService.listPendingChanges(lastId, BATCH_LIMIT);
             } catch (Exception e) {
                 log.error("查询 mkt_mail_status 待同步数据失败 lastId={}", lastId, e);
                 break;
             }
-            if (result == null || !result.isSuccess()) {
-                log.warn("查询 mkt_mail_status 失败 message={}", result == null ? null : result.getMessage());
-                break;
-            }
-            List<MktMailStatusChangeRemoteDTO> changes = result.getResult();
             if (CollectionUtils.isEmpty(changes)) {
                 break;
             }
 
             List<Long> syncedMailIds = new ArrayList<>();
             Long batchLastId = lastId;
-            for (MktMailStatusChangeRemoteDTO change : changes) {
+            for (MktMailStatusEntity change : changes) {
                 if (change == null || change.getMailId() == null) {
                     continue;
                 }
                 if (change.getId() != null) {
                     batchLastId = change.getId();
                 }
-                // 2. 按邮件 id 修改 market_emails
+                // 2. 业务库:按 email_id = mail_id 更新 market_emails
                 ApplyResult applyResult = applyToMarketEmails(change);
                 if (applyResult == ApplyResult.UPDATED) {
                     totalUpdated++;
                     syncedMailIds.add(change.getMailId());
                 } else if (applyResult == ApplyResult.MATCHED_NO_CHANGE) {
-                    // 业务表已对齐,仍需回写同步时间,避免反复拉取
                     syncedMailIds.add(change.getMailId());
                 }
                 // UNMATCHED:尚无 email_id 关联,不 ack,下次继续等
             }
 
-            // 3. 同步后回写 mkt_mail_status.status_updated_at
+            // 3. 邮件库:回写 status_updated_at
             if (!syncedMailIds.isEmpty()) {
                 try {
-                    Result<?> ack = mktMailRemoteClient.ackStatusSync(syncedMailIds);
-                    if (ack == null || !ack.isSuccess()) {
-                        log.warn("回写 mkt_mail_status.status_updated_at 失败 mailIds={} message={}",
-                                syncedMailIds, ack == null ? null : ack.getMessage());
-                        // ack 失败则不再继续翻页,避免漏确认
+                    boolean ackOk = mktMailStatusService.ackStatusSync(syncedMailIds);
+                    if (!ackOk) {
+                        log.warn("回写 mkt_mail_status.status_updated_at 失败 mailIds={}", syncedMailIds);
                         break;
                     }
                 } catch (Exception e) {
@@ -117,7 +104,7 @@ public class MarketEmailsStatusSyncServiceImpl implements MarketEmailsStatusSync
         return totalUpdated;
     }
 
-    private ApplyResult applyToMarketEmails(MktMailStatusChangeRemoteDTO change) {
+    private ApplyResult applyToMarketEmails(MktMailStatusEntity change) {
         MarketEmailsEntity existing = marketEmailsService.getOne(new LambdaQueryWrapper<MarketEmailsEntity>()
                 .eq(MarketEmailsEntity::getEmailId, change.getMailId())
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
@@ -149,6 +136,26 @@ public class MarketEmailsStatusSyncServiceImpl implements MarketEmailsStatusSync
             update.set(MarketEmailsEntity::getHasReply, 1);
             changed = true;
         }
+        //补充首次打开时间(仅在业务表为空时写入,保持“首次”语义)
+        if (existing.getFirstOpenTime() == null && change.getFirstOpenTime() != null) {
+            update.set(MarketEmailsEntity::getFirstOpenTime, change.getFirstOpenTime());
+            changed = true;
+        }
+        //补充首次回复时间
+        if (existing.getFirstReplyTime() == null && change.getFirstReplyTime() != null) {
+            update.set(MarketEmailsEntity::getFirstReplyTime, change.getFirstReplyTime());
+            changed = true;
+        }
+        //补充首次回复邮件id
+        if (existing.getFirstReplyEmailId() == null && change.getFirstReplyEmailId() != null) {
+            update.set(MarketEmailsEntity::getFirstReplyEmailId, change.getFirstReplyEmailId());
+            changed = true;
+        }
+        //补充回复邮件主题
+        if (existing.getFirstReplyTheme() == null && change.getFirstReplyTheme() != null) {
+            update.set(MarketEmailsEntity::getFirstReplyTheme, change.getFirstReplyTheme());
+            changed = true;
+        }
         if (!changed) {
             return ApplyResult.MATCHED_NO_CHANGE;
         }
@@ -168,7 +175,7 @@ public class MarketEmailsStatusSyncServiceImpl implements MarketEmailsStatusSync
         return TRADE_STATUS_DRAFT;
     }
 
-    private static Integer resolveReader(MktMailStatusChangeRemoteDTO change) {
+    private static Integer resolveReader(MktMailStatusEntity change) {
         if (Integer.valueOf(1).equals(change.getOpened()) || Integer.valueOf(1).equals(change.getIsRead())) {
             return 1;
         }

+ 75 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsManagementVO.java

@@ -0,0 +1,75 @@
+package com.storlead.trade.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 邮件管理弹窗卡片 VO
+ * <p>
+ * 按 marketingCampaignId(MarketingCampaignEntity.id)查询,拼装一个营销活动下所有邮件卡片。
+ * </p>
+ *
+ * @author QClaw Generated
+ * @since 2026-08-27
+ */
+@Data
+@ApiModel(value = "MarketEmailsManagementVO", description = "邮件管理弹窗卡片 VO")
+public class MarketEmailsManagementVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "邮件主键 id")
+    private Long id;
+
+    @ApiModelProperty(value = "营销活动 id")
+    private Long marketingCampaignId;
+
+    @ApiModelProperty(value = "营销活动名(marketing_campaign.name)")
+    private String campaignName;
+
+    @ApiModelProperty(value = "客户 id")
+    private Long customerId;
+
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "客户联系人称呼(如 admin)")
+    private String customerContact;
+
+    @ApiModelProperty(value = "收件人邮箱(market_emails.recipient)")
+    private String recipient;
+
+    @ApiModelProperty(value = "阶段名(首次介绍 / 报价邮件 等,来自 marketing_campaign_sop_detail.name)")
+    private String stageName;
+
+    @ApiModelProperty(value = "预计发送时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date expectSendTime;
+
+    @ApiModelProperty(value = "邮件状态(1 已发送 / 2 待发送 / 0 已取消)")
+    private Integer status;
+
+    @ApiModelProperty(value = "邮件内容")
+    private String content;
+
+    @ApiModelProperty(value = "是否已读(0 否 1 是)")
+    private Integer hasReader;
+
+    @ApiModelProperty(value = "是否已保护(0 否 1 是)")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "是否已暂停(0 否 1 是)")
+    private Integer hasSuspend;
+
+    @ApiModelProperty(value = "首次打开时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date firstOpenTime;
+
+    @ApiModelProperty(value = "关联的营销活动 SOP 详情 id(market_emails.marketing_campaign_sop_detail_id)")
+    private Long marketingCampaignSopDetailId;
+}

+ 53 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsProtectVO.java

@@ -0,0 +1,53 @@
+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.util.Date;
+
+/**
+ * 人工干预列表响应 VO(客户保护白名单 / 营销暂停)
+ * <p>
+ * 数据源:market_emails.has_protect=1 或 has_suspend=1 的记录
+ * 聚合:按 recipient 聚合(一个客户多个保护记录合并)
+ * 排序:保护时间 DESC(最近保护排前)
+ * </p>
+ *
+ * @author Generated
+ */
+@Data
+@ApiModel(value = "MarketEmailsProtectVO", description = "人工干预列表(按 recipient 聚合)")
+public class MarketEmailsProtectVO implements Serializable {
+
+    @ApiModelProperty(value = "邮件主键 id(用于移除保护接口)")
+    private Long id;
+
+    @ApiModelProperty(value = "发件人邮箱(客户邮箱)")
+    private String recipient;
+
+    @ApiModelProperty(value = "客户id")
+    private Long customerId;
+
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "保护原因")
+    private String protectReason;
+
+    @ApiModelProperty(value = "保护时间(取该 recipient 下最早一次保护的时间)")
+    @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 protectTime;
+
+    @ApiModelProperty(value = "是否保护(0未保护、1保护)")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "是否暂停(0未暂停、1暂停)")
+    private Integer hasSuspend;
+}

+ 62 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsReplyVO.java

@@ -0,0 +1,62 @@
+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.util.Date;
+
+/**
+ * 回复邮件的客户列表响应 VO(按 recipient 聚合)
+ * <p>
+ * 数据源:market_emails.has_reply = 1 的记录
+ * 排序:第一封回复时间 ASC(最早的回复排前面)
+ * 聚合:同一 recipient 多条回复合并为一张卡片,replyCount 累加
+ * </p>
+ *
+ * @author Generated
+ */
+@Data
+@ApiModel(value = "MarketEmailsReplyVO", description = "回复邮件的客户(按发件人邮箱聚合)")
+public class MarketEmailsReplyVO implements Serializable {
+
+    @ApiModelProperty(value = "发件人邮箱(客户邮箱)")
+    private String recipient;
+
+    @ApiModelProperty(value = "客户id(同一 recipient 取最早回复那一条所属的 customerId)")
+    private Long customerId;
+
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "客户优先级标签(如:高优先级/高价值)")
+    private String customerLevel;
+
+    @ApiModelProperty(value = "客户等级标签")
+    private String customerLevelLabel;
+
+    @ApiModelProperty(value = "核心标签(AI 给客户打的标签,逗号分隔)")
+    private String coreTags;
+
+    @ApiModelProperty(value = "回复次数(同 recipient 下的邮件回复记录数)")
+    private Long replyCount;
+
+    @ApiModelProperty(value = "首次回复时间")
+    @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 firstReplyTime;
+
+    @ApiModelProperty(value = "回复内容(复用 first_reply_theme 字段,DB 未单独建 reply_content)")
+    private String replyContent;
+
+    @ApiModelProperty(value = "是否保护(0未保护、1保护)")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "是否暂停(0未暂停、1暂停)")
+    private Integer hasSuspend;
+}