Explorar el Código

Merge remote-tracking branch 'origin/master'

chenkq hace 1 día
padre
commit
bf886e128d
Se han modificado 21 ficheros con 1181 adiciones y 29 borrados
  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. 1 0
      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. 49 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketEmailsController.java
  9. 1 13
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketingCampaignSopDetailController.java
  10. 31 4
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/MarketEmailsDTO.java
  11. 42 4
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MarketEmailsEntity.java
  12. 48 1
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketEmailsService.java
  13. 4 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketingCampaignSopDetailService.java
  14. 3 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerTradeServiceImpl.java
  15. 566 6
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java
  16. 20 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsStatusSyncServiceImpl.java
  17. 116 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignSopDetailServiceImpl.java
  18. 75 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsManagementVO.java
  19. 45 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsOpenVO.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  #对话

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

@@ -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  #对话

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

+ 49 - 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;
@@ -32,6 +33,48 @@ public class MarketEmailsController {
         return marketEmailsService.getList(marketEmailsDTO);
     }
 
+    @PostMapping("/open-list")
+    @ApiOperation("营销: 打开邮件的客户列表")
+    public Result<Object> openList(@RequestBody MarketEmailsDTO marketEmailsDTO) {
+        return marketEmailsService.getOpenList(marketEmailsDTO);
+    }
+
+    @PostMapping("/protect")
+    @ApiOperation("营销: 保护邮件(按 id 或 idList)")
+    public Result<Object> protect(@RequestBody MarketEmailsDTO marketEmailsDTO) {
+        return marketEmailsService.protect(marketEmailsDTO);
+    }
+
+    @PostMapping("/suspend")
+    @ApiOperation("营销: 暂停邮件(按 id 或 idList)")
+    public Result<Object> suspend(@RequestBody MarketEmailsDTO marketEmailsDTO) {
+        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) {
@@ -70,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);
+    }
 }

+ 1 - 13
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/controller/MarketingCampaignSopDetailController.java

@@ -368,19 +368,7 @@ public class MarketingCampaignSopDetailController {
     @PostMapping("/edit")
     @ApiOperation(value = "编辑营销活动SOP明细", notes = "编辑营销活动SOP明细")
     public Result<?> edit(@RequestBody MarketingCampaignSopDetailDTO dto) {
-        if (ObjectUtils.isEmpty(dto.getId())) {
-            return Result.error("id不能为空");
-        }
-        MarketingCampaignSopDetailEntity entity = new MarketingCampaignSopDetailEntity();
-        BeanUtils.copyProperties(dto, entity);
-        boolean isOk = marketingCampaignSopDetailService.updateById(entity);
-        if (!isOk) {
-            return Result.error("修改失败");
-        }
-        if (!ObjectUtils.isEmpty(dto.getEmailContent())){
-
-        }
-        return Result.result(entity);
+        return marketingCampaignSopDetailService.editMarketingCampaignSopDetail(dto);
     }
 
     /**

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

@@ -1,13 +1,17 @@
 package com.storlead.trade.dto;
 
+import com.alibaba.fastjson.annotation.JSONField;
 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.fasterxml.jackson.annotation.JsonFormat;
 import com.storlead.framework.mybatis.page.Page;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
 
+import java.util.Date;
 import java.util.List;
 
 @Data
@@ -26,15 +30,15 @@ public class MarketEmailsDTO extends Page {
     @TableField("marketing_campaign_id")
     private Long marketingCampaignId;
 
-    @ApiModelProperty(value = "邮件模板id")
-    @TableField("sop_detail_id")
-    private Long sopDetailId;
+    @ApiModelProperty(value = "营销活动关联邮件模板id")
+    @TableField("marketing_campaign_sop_detail_id")
+    private Long marketingCampaignSopDetailId;
 
     @ApiModelProperty(value = "对应的邮件id")
     @TableField("email_id")
     private Long emailId;
 
-    @ApiModelProperty(value = "状态:0草稿,1已发送,2发送失败")
+    @ApiModelProperty(value = "发送状态:-2草稿 -1待定时 0发送中 1成功 2失败;收信记录一般为1")
     @TableField("status")
     private Integer status;
 
@@ -48,4 +52,27 @@ public class MarketEmailsDTO extends Page {
 
     @ApiModelProperty(value = "id列表(批量操作用)")
     private List<Long> idList;
+
+    @ApiModelProperty(value = "是否保护(0未保护、1保护)")
+    @TableField("has_protect")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "是否暂停(0未暂停、1暂停)")
+    @TableField("has_suspend")
+    private Integer hasSuspend;
+
+    @ApiModelProperty(value = "保护原因(仅添加/更新保护时使用)")
+    @TableField("protect_reason")
+    private String protectReason;
+
+    @ApiModelProperty(value = "发件人邮箱")
+    @TableField("recipient")
+    private String recipient;
+
+    @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_open_time")
+    private Date firstOpenTime;
 }

+ 42 - 4
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MarketEmailsEntity.java

@@ -27,15 +27,15 @@ public class MarketEmailsEntity extends SysBaseField {
     @TableField("marketing_campaign_id")
     private Long marketingCampaignId;
 
-    @ApiModelProperty(value = "邮件模板id")
-    @TableField("sop_detail_id")
-    private Long sopDetailId;
+    @ApiModelProperty(value = "营销活动关联邮件模板id")
+    @TableField("marketing_campaign_sop_detail_id")
+    private Long marketingCampaignSopDetailId;
 
     @ApiModelProperty(value = "对应的邮件id")
     @TableField("email_id")
     private Long emailId;
 
-    @ApiModelProperty(value = "状态:0草稿,1已发送,2发送失败")
+    @ApiModelProperty(value = "发送状态:-2草稿 -1待定时 0发送中 1成功 2失败;收信记录一般为1")
     @TableField("status")
     private Integer status;
 
@@ -58,4 +58,42 @@ public class MarketEmailsEntity extends SysBaseField {
     @TableField("content")
     private String content;
 
+    @ApiModelProperty(value = "是否保护(0未保护、1保护)")
+    @TableField("has_protect")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "是否暂停(0未暂停、1暂停)")
+    @TableField("has_suspend")
+    private Integer hasSuspend;
+
+    @ApiModelProperty(value = "发件人邮箱")
+    @TableField("recipient")
+    private String recipient;
+
+    @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_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;
+
 }

+ 48 - 1
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;
@@ -18,7 +19,53 @@ public interface MarketEmailsService extends MyBaseService<MarketEmailsEntity> {
 
     Result<Object> edit(MarketEmailsDTO marketEmailsDTO);
 
+    /**
+     * 打开邮件的客户列表:按 marketingCampaignId 过滤 hasReader=1 的记录,
+     * 按 recipient 聚合(统计点击次数、取最早首次打开时间)。
+     */
+    Result<Object> getOpenList(MarketEmailsDTO marketEmailsDTO);
+
+    /**
+     * 保护邮件(按 id 或 idList,hasProtect 置 1)
+     */
+    Result<Object> protect(MarketEmailsDTO marketEmailsDTO);
+
+    /**
+     * 暂停邮件(按 id 或 idList,hasSuspend 置 1)
+     */
+    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);
 
-}
+}

+ 4 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MarketingCampaignSopDetailService.java

@@ -1,10 +1,14 @@
 package com.storlead.trade.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.storlead.framework.common.result.Result;
+import com.storlead.trade.dto.MarketingCampaignSopDetailDTO;
 import com.storlead.trade.entity.MarketingCampaignSopDetailEntity;
 
 /**
  * 营销活动SOP明细 Service接口
  */
 public interface MarketingCampaignSopDetailService extends IService<MarketingCampaignSopDetailEntity> {
+
+    Result<?> editMarketingCampaignSopDetail(MarketingCampaignSopDetailDTO dto);
 }

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

@@ -56,6 +56,9 @@ public class CustomerTradeServiceImpl
         if (!ObjectUtils.isEmpty(dto.getId())) {
             wrapper.eq(CustomerEntity::getId, dto.getId());
         }
+        if (!CollectionUtils.isEmpty(dto.getIdList())){
+            wrapper.in(CustomerEntity::getId, dto.getIdList());
+        }
         if (!ObjectUtils.isEmpty(dto.getCustomerCode())) {
             wrapper.eq(CustomerEntity::getCustomerCode, dto.getCustomerCode());
         }

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

@@ -21,7 +21,10 @@ 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;
 import org.springframework.beans.BeanUtils;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -34,12 +37,14 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
+import static org.slf4j.LoggerFactory.getLogger;
+
 @Service
 public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMapper, MarketEmailsEntity>
         implements MarketEmailsService {
 
-    private static final org.slf4j.Logger log =
-            org.slf4j.LoggerFactory.getLogger(MarketEmailsServiceImpl.class);
+    private static final Logger log =
+            getLogger(MarketEmailsServiceImpl.class);
 
     /** AI workflow 单次排期最多重试次数(应对模型思考被截断、未吐出 JSON 的情况) */
     private static final int WORKFLOW_MAX_RETRY = 3;
@@ -53,12 +58,17 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
     @Resource
     private CustomerTradeService customerTradeService;
     @Resource
+    @org.springframework.context.annotation.Lazy
     private MarketingCampaignSopDetailService marketingCampaignSopDetailEntityService;
     @Resource
     private SopDetailService sopDetailService;
     @Resource
     private WorkflowsService workflowsService;
 
+    @Resource
+    @org.springframework.context.annotation.Lazy
+    private MarketingCampaignService marketingCampaignService;
+
 
     @Override
     public Result<Object> getList(MarketEmailsDTO dto) {
@@ -76,8 +86,8 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         if (!ObjectUtils.isEmpty(dto.getMarketingCampaignId())) {
             wrapper.eq(MarketEmailsEntity::getMarketingCampaignId, dto.getMarketingCampaignId());
         }
-        if (!ObjectUtils.isEmpty(dto.getSopDetailId())) {
-            wrapper.eq(MarketEmailsEntity::getSopDetailId, dto.getSopDetailId());
+        if (!ObjectUtils.isEmpty(dto.getMarketingCampaignSopDetailId())) {
+            wrapper.eq(MarketEmailsEntity::getMarketingCampaignSopDetailId, dto.getMarketingCampaignSopDetailId());
         }
         if (!ObjectUtils.isEmpty(dto.getEmailId())) {
             wrapper.eq(MarketEmailsEntity::getEmailId, dto.getEmailId());
@@ -119,7 +129,6 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
     }
 
     @Override
-    @Transactional(rollbackFor = Throwable.class)
     public Result<Object> edit(MarketEmailsDTO dto) {
         if (ObjectUtils.isEmpty(dto.getId())) {
             return Result.error("id不能为空");
@@ -140,6 +149,551 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         return Result.ok();
     }
 
+    @Override
+    public Result<Object> getOpenList(MarketEmailsDTO dto) {
+        if (dto == null) {
+            dto = new MarketEmailsDTO();
+        }
+        // 打开邮件的客户 = hasReader=1 且 recipient 不为空
+        // 策略:单活动下打开邮件的邮件数通常几百~几千,先查全集在内存里按 recipient 分组 + 分页
+        LambdaQueryWrapper<MarketEmailsEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        wrapper.eq(MarketEmailsEntity::getHasReader, 1);
+        if (!ObjectUtils.isEmpty(dto.getMarketingCampaignId())) {
+            wrapper.eq(MarketEmailsEntity::getMarketingCampaignId, dto.getMarketingCampaignId());
+        }
+        if (dto.getRecipient() != null && !dto.getRecipient().trim().isEmpty()) {
+            wrapper.eq(MarketEmailsEntity::getRecipient, dto.getRecipient().trim());
+        }
+        wrapper.isNotNull(MarketEmailsEntity::getRecipient);
+        wrapper.ne(MarketEmailsEntity::getRecipient, "");
+        // 只查需要的列,减少数据传输
+        wrapper.select(MarketEmailsEntity::getRecipient,
+                MarketEmailsEntity::getCustomerId,
+                MarketEmailsEntity::getFirstOpenTime,
+                MarketEmailsEntity::getHasProtect,
+                MarketEmailsEntity::getHasSuspend);
+        List<MarketEmailsEntity> all = this.list(wrapper);
+        // 按 recipient 聚合
+        Map<String, com.storlead.trade.vo.MarketEmailsOpenVO> 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.MarketEmailsOpenVO vo = aggMap.computeIfAbsent(r, k -> {
+                    com.storlead.trade.vo.MarketEmailsOpenVO v = new com.storlead.trade.vo.MarketEmailsOpenVO();
+                    v.setRecipient(k);
+                    v.setClickCount(0L);
+                    return v;
+                });
+                vo.setClickCount(vo.getClickCount() + 1);
+                // 取最早首次打开时间,同时记下该条记录所属的 customerId
+                if (e.getFirstOpenTime() != null) {
+                    if (vo.getFirstOpenTime() == null || e.getFirstOpenTime().before(vo.getFirstOpenTime())) {
+                        vo.setFirstOpenTime(e.getFirstOpenTime());
+                        vo.setCustomerId(e.getCustomerId());
+                    }
+                }
+                // 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);
+                }
+            }
+        }
+        // 按首次打开时间倒序
+        List<com.storlead.trade.vo.MarketEmailsOpenVO> sorted = new java.util.ArrayList<>(aggMap.values());
+        sorted.sort((a, b) -> {
+            if (a.getFirstOpenTime() == null && b.getFirstOpenTime() == null) return 0;
+            if (a.getFirstOpenTime() == null) return 1;
+            if (b.getFirstOpenTime() == null) return -1;
+            return b.getFirstOpenTime().compareTo(a.getFirstOpenTime());
+        });
+        // 内存分页
+        long pageIndex = dto.getPageIndex() != null && dto.getPageIndex() > 0 ? dto.getPageIndex() : 1L;
+        long pageSize = dto.getPageSize() != null && dto.getPageSize() > 0 ? dto.getPageSize() : 50L;
+        long total = sorted.size();
+        long from = Math.min((pageIndex - 1) * pageSize, total);
+        long to = Math.min(from + pageSize, total);
+        List<com.storlead.trade.vo.MarketEmailsOpenVO> pageList = sorted.subList((int) from, (int) to);
+        com.baomidou.mybatisplus.core.metadata.IPage<com.storlead.trade.vo.MarketEmailsOpenVO> page =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pageIndex, pageSize, total);
+        page.setRecords(pageList);
+        // 回填客户名称(批量查 customer 表)
+        if (pageList != null && !pageList.isEmpty()) {
+            java.util.Set<Long> customerIdSet = new java.util.LinkedHashSet<>();
+            for (com.storlead.trade.vo.MarketEmailsOpenVO v : pageList) {
+                if (v.getCustomerId() != null) {
+                    customerIdSet.add(v.getCustomerId());
+                }
+            }
+            if (!customerIdSet.isEmpty()) {
+                try {
+                    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 (CustomerEntity c : customers) {
+                            if (c.getId() != null) {
+                                idToName.put(c.getId(), c.getCustomerName());
+                            }
+                        }
+                        for (com.storlead.trade.vo.MarketEmailsOpenVO v : pageList) {
+                            if (v.getCustomerId() != null) {
+                                v.setCustomerName(idToName.get(v.getCustomerId()));
+                            }
+                        }
+                    }
+                } catch (Exception ex) {
+                    log.warn("查询客户名称失败,customerIds={} err={}", customerIdSet, ex.getMessage());
+                }
+            }
+        }
+        return Result.result(page);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Result<Object> protect(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);
+        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("保护失败");
+        }
+        // 同步更新 emails 表的 has_protect
+        LambdaQueryWrapper<MarketEmailsEntity> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.in(MarketEmailsEntity::getId, ids);
+        queryWrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        List<MarketEmailsEntity> list = this.list(queryWrapper);
+        List<Long> emailIdList = list.stream().filter(e -> e.getEmailId() != null)
+                .map(MarketEmailsEntity::getEmailId).collect(Collectors.toList());
+        if (!CollectionUtils.isEmpty(emailIdList)) {
+//            emailService.updateEmailHasProtect(emailIdList, dto.getHasProtect());
+        }
+        return Result.ok();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Result<Object> suspend(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);
+        wrapper.set(MarketEmailsEntity::getHasSuspend, dto.getHasSuspend());
+        boolean ok = this.update(wrapper);
+        if (!ok) {
+            return Result.error("暂停失败");
+        }
+        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) {
@@ -247,10 +801,16 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
                     if (ObjectUtils.isEmpty(sopDetail)) {
                         continue;
                     }
+                    //取marketingCampaignSopDetailEntityList中marketingId=marketingCampaignEntity.getId()且sopDetailId=sopDetail.getId()的记录
+                    MarketingCampaignSopDetailEntity marketingCampaignSopDetail = marketingCampaignSopDetailEntityList.stream()
+                            .filter(detail -> detail.getMarketingId().equals(marketingCampaignEntity.getId())
+                                    && detail.getSopDetailId().equals(sopDetail.getId()))
+                            .findFirst()
+                            .orElse(null);
                     MarketEmailsEntity marketEmail = new MarketEmailsEntity();
                     marketEmail.setMarketingCampaignId(marketingCampaignEntity.getId());
                     marketEmail.setCustomerId(customerVO.getCustomerId());
-                    marketEmail.setSopDetailId(sopDetail.getId());
+                    marketEmail.setMarketingCampaignSopDetailId(marketingCampaignSopDetail.getId());
                     marketEmail.setExpectSendTime(scheduleStage.getBeijingDatetime());
                     marketEmailList.add(marketEmail);
                 }

+ 20 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsStatusSyncServiceImpl.java

@@ -136,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;
         }

+ 116 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignSopDetailServiceImpl.java

@@ -1,12 +1,33 @@
 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.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.storlead.framework.common.constant.DSConstants;
+import com.storlead.framework.common.result.Result;
+import com.storlead.mail.pojo.entity.MktMailMessageEntity;
+import com.storlead.mail.service.MktMailMessageService;
+import com.storlead.trade.dto.CustomerDTO;
+import com.storlead.trade.dto.MarketingCampaignSopDetailDTO;
+import com.storlead.trade.entity.CustomerEntity;
+import com.storlead.trade.entity.MarketEmailsEntity;
 import com.storlead.trade.entity.MarketingCampaignSopDetailEntity;
 import com.storlead.trade.mapper.MarketingCampaignSopDetailMapper;
+import com.storlead.trade.service.CustomerTradeService;
+import com.storlead.trade.service.MarketEmailsService;
 import com.storlead.trade.service.MarketingCampaignSopDetailService;
 import com.storlead.trade.service.impl.support.TradeDataSourceServiceImpl;
+import com.storlead.trade.vo.CustomerVO;
+import org.springframework.beans.BeanUtils;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
+import org.springframework.util.ObjectUtils;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 营销活动SOP明细 Service实现
@@ -16,4 +37,99 @@ import org.springframework.stereotype.Service;
 public class MarketingCampaignSopDetailServiceImpl
         extends TradeDataSourceServiceImpl<MarketingCampaignSopDetailMapper, MarketingCampaignSopDetailEntity>
         implements MarketingCampaignSopDetailService {
+    @Resource
+    private MarketEmailsService marketEmailsService;
+    @Resource
+    private CustomerTradeService customerService;
+    @Resource
+    private MktMailMessageService mktMailMessageService;
+
+    @Override
+    public Result<?> editMarketingCampaignSopDetail(MarketingCampaignSopDetailDTO dto) {
+        if (ObjectUtils.isEmpty(dto.getId())) {
+            return Result.error("id不能为空");
+        }
+        MarketingCampaignSopDetailEntity entity = new MarketingCampaignSopDetailEntity();
+        BeanUtils.copyProperties(dto, entity);
+        boolean isOk = this.updateById(entity);
+        if (!isOk) {
+            return Result.error("修改失败");
+        }
+        if (!ObjectUtils.isEmpty(dto.getEmailContent())){
+            //写入每个客户的邮件内容
+            this.updateEmailContent(dto.getId(), dto.getEmailContent());
+        }
+        return Result.result(entity);
+    }
+
+    private void updateEmailContent(Long id, String emailContent) {
+        //查询营销活动关联的所有客户
+//        MarketingCampaignSopDetailEntity entity = this.getById(id);
+        LambdaQueryWrapper<MarketEmailsEntity> marketEmailsEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
+        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getMarketingCampaignSopDetailId, id);
+        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getIsDelete, 0);
+        List<MarketEmailsEntity> marketEmailsEntityList = marketEmailsService.list(marketEmailsEntityLambdaQueryWrapper);
+
+        List<Long> customerIdList = marketEmailsEntityList.stream().map(MarketEmailsEntity::getCustomerId).collect(Collectors.toList());
+        CustomerDTO dto = new CustomerDTO();
+        dto.setIdList(customerIdList);
+        dto.setPageIndex(1);
+        dto.setPageSize(customerIdList.size());
+        IPage<CustomerVO> customerPageList = customerService.getList(dto);
+        if (customerPageList == null || customerPageList.getRecords() == null || customerPageList.getRecords().size() == 0) {
+            return;
+        }
+        List<CustomerVO> customerVOList = customerPageList.getRecords();
+
+
+//        List<CustomerEntity> customerEntityList = customerService.listByIds(customerIdList);
+        Map<Long,CustomerVO> customerEntityMap = customerVOList.stream().collect(Collectors.toMap(CustomerVO::getId, customerVO -> customerVO));
+        for (MarketEmailsEntity marketEmailsEntity : marketEmailsEntityList) {
+            CustomerVO customerEntity = customerEntityMap.get(marketEmailsEntity.getCustomerId());
+            if (customerEntity != null) {
+                //修改emailContent中全部的${customerName}$为customerEntity.getCustomerName()
+                emailContent = emailContent.replace("${customerName}$", customerEntity.getCustomerName());
+                marketEmailsEntity.setContent(emailContent);
+            }
+            if (!ObjectUtils.isEmpty(marketEmailsEntity.getCustomerId()) && !ObjectUtils.isEmpty(customerEntityMap.get(marketEmailsEntity.getCustomerId()).getCustomerCompany()) && !ObjectUtils.isEmpty(customerEntityMap.get(marketEmailsEntity.getCustomerId()).getCustomerCompany().getEmail())){
+                marketEmailsEntity.setRecipient(customerEntityMap.get(marketEmailsEntity.getCustomerId()).getCustomerCompany().getEmail());
+            }
+
+        }
+        boolean isOk = marketEmailsService.updateBatchById(marketEmailsEntityList);
+        if (!isOk) {
+            return;
+        }
+
+        //异步写入邮件发送时间及内容
+        this.insertMailMessage(marketEmailsEntityList,customerEntityMap);
+    }
+
+    @Async
+    public void insertMailMessage(List<MarketEmailsEntity> marketEmailsEntityList, Map<Long, CustomerVO> customerEntityMap) {
+        for (MarketEmailsEntity marketEmailsEntity : marketEmailsEntityList) {
+            if (ObjectUtils.isEmpty(marketEmailsEntity.getCustomerId()) || ObjectUtils.isEmpty(customerEntityMap.get(marketEmailsEntity.getCustomerId()).getCustomerCompany()) || ObjectUtils.isEmpty(customerEntityMap.get(marketEmailsEntity.getCustomerId()).getCustomerCompany().getEmail())){
+                continue;
+            }
+            if (!ObjectUtils.isEmpty(marketEmailsEntity.getEmailId())){
+                LambdaUpdateWrapper<MktMailMessageEntity> lambdaUpdateWrapper = new LambdaUpdateWrapper<>();
+                lambdaUpdateWrapper.eq(MktMailMessageEntity::getId, marketEmailsEntity.getEmailId());
+//                lambdaUpdateWrapper.set(MktMailMessageEntity::getStatus, -1);
+                lambdaUpdateWrapper.set(MktMailMessageEntity::getContent, marketEmailsEntity.getContent());
+                mktMailMessageService.update(lambdaUpdateWrapper);
+            }else {
+                MktMailMessageEntity mktMailMessageEntity = new MktMailMessageEntity();
+                mktMailMessageEntity.setStatus(-1);
+                mktMailMessageEntity.setDelaySendTime(marketEmailsEntity.getExpectSendTime());
+                mktMailMessageEntity.setContent(marketEmailsEntity.getContent());
+                mktMailMessageEntity.setRecipient(customerEntityMap.get(marketEmailsEntity.getCustomerId()).getCustomerCompany().getEmail());
+                mktMailMessageEntity.setIsTrack(1);
+                mktMailMessageService.save(mktMailMessageEntity);
+                if (mktMailMessageEntity.getId() != null) {
+                    marketEmailsEntity.setEmailId(mktMailMessageEntity.getId());
+                }
+            }
+        }
+        boolean isOk = marketEmailsService.updateBatchById(marketEmailsEntityList);
+    }
 }

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

+ 45 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsOpenVO.java

@@ -0,0 +1,45 @@
+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 聚合)
+ *
+ * @author Generated
+ */
+@Data
+@ApiModel(value = "MarketEmailsOpenVO", description = "打开邮件的客户(按发件人邮箱聚合)")
+public class MarketEmailsOpenVO implements Serializable {
+
+    @ApiModelProperty(value = "发件人邮箱(客户邮箱)")
+    private String recipient;
+
+    @ApiModelProperty(value = "客户id(同一 recipient 取最早打开那一条所属的 customerId)")
+    private Long customerId;
+
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "点击次数(同 recipient 下的邮件打开记录数)")
+    private Long clickCount;
+
+    @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 firstOpenTime;
+
+    @ApiModelProperty(value = "是否保护(0未保护、1保护)")
+    private Integer hasProtect;
+
+    @ApiModelProperty(value = "是否暂停(0未暂停、1暂停)")
+    private Integer hasSuspend;
+}

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