Ver código fonte

Merge remote-tracking branch 'origin/master'

zhujing 1 semana atrás
pai
commit
a37458a847
32 arquivos alterados com 538 adições e 38 exclusões
  1. 5 0
      java/storlead-api/src/main/resources/application.yml
  2. 45 2
      java/storlead-mail/storlead-mail-biz/src/main/java/com/storlead/mail/service/impl/MktMailMessageServiceImpl.java
  3. 14 0
      java/storlead-mail/storlead-mail-spi/src/main/java/com/storlead/mail/service/MktMailMessageService.java
  4. 3 0
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/InsideMessageRecordServiceImpl.java
  5. 3 0
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/InsideMessageSendLogServiceImpl.java
  6. 3 0
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/MessageService.java
  7. 3 0
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/MessageTemplateEventDetailServiceImpl.java
  8. 3 0
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/MessageTemplateEventGroupServiceImpl.java
  9. 3 0
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/UserMessageNoticeConfigServiceImpl.java
  10. 14 0
      java/storlead-sasa/storlead-trade/sql/mkt_mail_inbox_cursor.sql
  11. 28 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MktMailInboxCursorEntity.java
  12. 3 2
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/enums/TradeMessageTypeEnum.java
  13. 9 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/mapper/MktMailInboxCursorMapper.java
  14. 7 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MktMailInboxCursorService.java
  15. 12 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MktMailNewNotifyService.java
  16. 95 6
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java
  17. 18 6
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignServiceImpl.java
  18. 19 5
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignSopDetailServiceImpl.java
  19. 16 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MktMailInboxCursorServiceImpl.java
  20. 177 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MktMailNewNotifyServiceImpl.java
  21. 1 1
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/TradeMessageAssembleServiceImpl.java
  22. 1 1
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/task/MarketStartOrEndTask.java
  23. 33 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/task/MktMailNewNotifyTask.java
  24. 7 0
      java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/MarketEmailsReplyVO.java
  25. 3 3
      ui/smarttrade-platform/src/components/email/email.vue
  26. 1 1
      ui/smarttrade-platform/src/components/layout/TopBar.vue
  27. 4 3
      ui/smarttrade-platform/src/components/marketing/aiGenerator.vue
  28. 1 1
      ui/smarttrade-platform/src/components/marketing/interventionCustomers.vue
  29. 2 2
      ui/smarttrade-platform/src/components/marketing/openedCustomers.vue
  30. 2 2
      ui/smarttrade-platform/src/components/marketing/replyCustomers.vue
  31. 1 1
      ui/smarttrade-platform/src/views/MarketingView.vue
  32. 2 2
      ui/smarttrade-platform/src/views/emails/edit.vue

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

@@ -111,6 +111,11 @@ storlead:
           - com.storlead.system.service
           - com.storlead.system.mapper
         datasource: master
+      # 消息模板 / 站内信必须走 master(避免被 trade/sales 外层数据源污染)
+      - packages:
+          - com.storlead.message.service
+          - com.storlead.message.mapper
+        datasource: master
 
 management:
   health:

+ 45 - 2
java/storlead-mail/storlead-mail-biz/src/main/java/com/storlead/mail/service/impl/MktMailMessageServiceImpl.java

@@ -1,14 +1,17 @@
 package com.storlead.mail.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.mail.enums.EmailBoxEnum;
 import com.storlead.mail.mapper.MktMailMessageMapper;
 import com.storlead.mail.pojo.entity.MktMailMessageEntity;
 import com.storlead.mail.service.MktMailMessageService;
 import com.storlead.mail.service.impl.support.MailDataSourceServiceImpl;
 import lombok.extern.log4j.Log4j2;
 import org.springframework.stereotype.Service;
-import javax.mail.*;
-import java.util.*;
 
+import java.util.Collections;
+import java.util.List;
 
 /**
  * 营销邮件:对齐 CRM 草稿 / 附件 / 延时 / 发送,成功后写入 mkt_mail_attachment。
@@ -17,4 +20,44 @@ import java.util.*;
 @Service
 public class MktMailMessageServiceImpl extends MailDataSourceServiceImpl<MktMailMessageMapper, MktMailMessageEntity> implements MktMailMessageService {
 
+    @Override
+    public List<MktMailMessageEntity> listNewInboxMails(Long lastId, int limit) {
+        if (limit <= 0) {
+            return Collections.emptyList();
+        }
+        LambdaQueryWrapper<MktMailMessageEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.select(
+                        MktMailMessageEntity::getId,
+                        MktMailMessageEntity::getFromAddr,
+                        MktMailMessageEntity::getOwnerBy,
+                        MktMailMessageEntity::getCreateBy)
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MktMailMessageEntity::getFolder, EmailBoxEnum.INBOX.code)
+                .and(w -> w.eq(MktMailMessageEntity::getIsRead, 0).or().isNull(MktMailMessageEntity::getIsRead))
+                .gt(lastId != null, MktMailMessageEntity::getId, lastId)
+                .orderByAsc(MktMailMessageEntity::getId)
+                .last("limit " + limit);
+        return list(wrapper);
+    }
+
+    @Override
+    public Long maxInboxUnreadId() {
+        MktMailMessageEntity unreadLatest = getOne(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .select(MktMailMessageEntity::getId)
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MktMailMessageEntity::getFolder, EmailBoxEnum.INBOX.code)
+                .and(w -> w.eq(MktMailMessageEntity::getIsRead, 0).or().isNull(MktMailMessageEntity::getIsRead))
+                .orderByDesc(MktMailMessageEntity::getId)
+                .last("limit 1"));
+        if (unreadLatest != null && unreadLatest.getId() != null) {
+            return unreadLatest.getId();
+        }
+        MktMailMessageEntity inboxLatest = getOne(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .select(MktMailMessageEntity::getId)
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MktMailMessageEntity::getFolder, EmailBoxEnum.INBOX.code)
+                .orderByDesc(MktMailMessageEntity::getId)
+                .last("limit 1"));
+        return inboxLatest == null ? 0L : inboxLatest.getId();
+    }
 }

+ 14 - 0
java/storlead-mail/storlead-mail-spi/src/main/java/com/storlead/mail/service/MktMailMessageService.java

@@ -3,10 +3,24 @@ package com.storlead.mail.service;
 import com.storlead.framework.mybatis.service.MyBaseService;
 import com.storlead.mail.pojo.entity.MktMailMessageEntity;
 
+import java.util.List;
+
 /**
  * 营销邮件投递业务(对齐 CRM:草稿、附件、延时、发送)。
  */
 public interface MktMailMessageService extends MyBaseService<MktMailMessageEntity> {
 
+    /**
+     * 营销收件箱未读新邮件(不含正文),按 id 升序分页。
+     *
+     * @param lastId 上一批最后一条 id,首页传 null
+     * @param limit  批量大小
+     */
+    List<MktMailMessageEntity> listNewInboxMails(Long lastId, int limit);
 
+    /**
+     * 收件箱未读邮件当前最大 id;没有未读则取收件箱最大 id。
+     */
+    Long maxInboxUnreadId();
 }
+

+ 3 - 0
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/InsideMessageRecordServiceImpl.java

@@ -4,6 +4,8 @@ import cn.hutool.core.util.StrUtil;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.common.enums.ErrorMsgCode;
 import com.storlead.framework.common.util.HtmlUtils;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.framework.util.LoginUserUtil;
 import com.storlead.message.pojo.entity.InsideMessageRecordEntity;
@@ -35,6 +37,7 @@ import java.util.regex.Pattern;
  */
 @Log4j2
 @Service
+@DS(DSConstants.DATASOURCE_MASTER)
 public class InsideMessageRecordServiceImpl extends MyBaseServiceImpl<InsideMessageRecordMapper, InsideMessageRecordEntity> implements InsideMessageRecordService {
 
     @Override

+ 3 - 0
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/InsideMessageSendLogServiceImpl.java

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.framework.util.LoginUserUtil;
 import com.storlead.message.pojo.dto.MessageDTO;
@@ -28,6 +30,7 @@ import java.util.Objects;
  * @since 2024-04-19
  */
 @Service
+@DS(DSConstants.DATASOURCE_MASTER)
 public class InsideMessageSendLogServiceImpl extends MyBaseServiceImpl<InsideMessageSendLogMapper, InsideMessageSendLogEntity> implements InsideMessageSendLogService {
 
     @Override

+ 3 - 0
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/MessageService.java

@@ -10,6 +10,8 @@ import com.storlead.message.service.InsideMessageRecordService;
 import com.storlead.message.service.MessageTemplateEventDetailService;
 import com.storlead.message.service.MessageTemplateEventGroupService;
 import lombok.extern.log4j.Log4j2;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.storlead.framework.common.constant.DSConstants;
 import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 import javax.annotation.Resource;
@@ -23,6 +25,7 @@ import java.util.*;
 
 @Log4j2
 @Service
+@DS(DSConstants.DATASOURCE_MASTER)
 public class MessageService {
 
     @Resource

+ 3 - 0
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/MessageTemplateEventDetailServiceImpl.java

@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.storlead.framework.common.util.HtmlUtils;
 import com.storlead.framework.common.util.encryptor.AccessKeyEncryptor;
 import com.storlead.framework.util.LoginUserUtil;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.framework.common.thread.ThreadPoolUtil;
 import com.storlead.message.pojo.entity.MessageTemplateEventDetailEntity;
@@ -40,6 +42,7 @@ import java.util.stream.Collectors;
  */
 @Log4j2
 @Service
+@DS(DSConstants.DATASOURCE_MASTER)
 public class MessageTemplateEventDetailServiceImpl extends MyBaseServiceImpl<MessageTemplateEventDetailMapper, MessageTemplateEventDetailEntity> implements MessageTemplateEventDetailService {
 
     @Value("${domainname}")

+ 3 - 0
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/MessageTemplateEventGroupServiceImpl.java

@@ -2,6 +2,8 @@ package com.storlead.message.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.message.pojo.entity.InsideMessageSendLogEntity;
 import com.storlead.message.pojo.entity.MessageTemplateEventGroupEntity;
@@ -21,6 +23,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  * @since 2024-04-19
  */
 @Service
+@DS(DSConstants.DATASOURCE_MASTER)
 public class MessageTemplateEventGroupServiceImpl extends MyBaseServiceImpl<MessageTemplateEventGroupMapper, MessageTemplateEventGroupEntity> implements MessageTemplateEventGroupService {
 
     @Override

+ 3 - 0
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/UserMessageNoticeConfigServiceImpl.java

@@ -1,7 +1,9 @@
 package com.storlead.message.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.dynamic.datasource.annotation.DS;
 import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.message.pojo.entity.UserMessageNoticeConfigEntity;
 import com.storlead.message.mapper.UserMessageNoticeConfigMapper;
 import com.storlead.message.service.UserMessageNoticeConfigService;
@@ -23,6 +25,7 @@ import java.util.stream.Collectors;
  * @since 2025-04-25
  */
 @Service
+@DS(DSConstants.DATASOURCE_MASTER)
 public class UserMessageNoticeConfigServiceImpl extends MyBaseServiceImpl<UserMessageNoticeConfigMapper, UserMessageNoticeConfigEntity> implements UserMessageNoticeConfigService {
 
     @Override

+ 14 - 0
java/storlead-sasa/storlead-trade/sql/mkt_mail_inbox_cursor.sql

@@ -0,0 +1,14 @@
+-- 营销收件箱新邮件提醒游标(记录已处理的未读邮件最大 id)
+CREATE TABLE IF NOT EXISTS mkt_mail_inbox_cursor (
+    id            BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    last_mail_id  BIGINT       NOT NULL DEFAULT 0 COMMENT '已处理的收件箱未读邮件最大id(mkt_mail_message.id)',
+    owner_by      BIGINT       DEFAULT NULL,
+    create_by     BIGINT       DEFAULT NULL,
+    update_by     BIGINT       DEFAULT NULL,
+    create_time   DATETIME     DEFAULT NULL,
+    update_time   DATETIME     DEFAULT NULL,
+    is_delete     INT          DEFAULT 0,
+    enabled       TINYINT      DEFAULT 1,
+    sort          INT          DEFAULT NULL,
+    PRIMARY KEY (id)
+) COMMENT='营销收件箱新邮件提醒游标';

+ 28 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/entity/MktMailInboxCursorEntity.java

@@ -0,0 +1,28 @@
+package com.storlead.trade.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;
+
+/**
+ * 营销收件箱新邮件提醒游标:记录已处理的未读邮件最大 id。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("mkt_mail_inbox_cursor")
+@ApiModel(value = "MktMailInboxCursorEntity", description = "营销收件箱新邮件提醒游标")
+public class MktMailInboxCursorEntity extends SysBaseField {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("已处理的收件箱未读邮件最大 id(mkt_mail_message.id)")
+    @TableField("last_mail_id")
+    private Long lastMailId;
+}

+ 3 - 2
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/enums/TradeMessageTypeEnum.java

@@ -12,8 +12,9 @@ import java.util.Map;
 @Getter
 public enum  TradeMessageTypeEnum {
 
-    CUSTOMIZE_EXAMPLE("0", "业务自定义"),
-    OTHER_EXTEND("999", "其他"),
+//    CUSTOMIZE_EXAMPLE("0", "业务自定义"),
+    MKT_REPLY_NEW_MAIL("10", "邮件"),
+//    OTHER_EXTEND("999", "其他"),
     ;
 
     /** 贸易应用 ID,对应 sys_app.id */

+ 9 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/mapper/MktMailInboxCursorMapper.java

@@ -0,0 +1,9 @@
+package com.storlead.trade.mapper;
+
+import com.storlead.trade.entity.MktMailInboxCursorEntity;
+import com.storlead.trade.mapper.support.TradeBaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MktMailInboxCursorMapper extends TradeBaseMapper<MktMailInboxCursorEntity> {
+}

+ 7 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MktMailInboxCursorService.java

@@ -0,0 +1,7 @@
+package com.storlead.trade.service;
+
+import com.storlead.framework.mybatis.service.MyBaseService;
+import com.storlead.trade.entity.MktMailInboxCursorEntity;
+
+public interface MktMailInboxCursorService extends MyBaseService<MktMailInboxCursorEntity> {
+}

+ 12 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/MktMailNewNotifyService.java

@@ -0,0 +1,12 @@
+package com.storlead.trade.service;
+
+/**
+ * 定时扫描营销收件箱新邮件并发消息提醒。
+ */
+public interface MktMailNewNotifyService {
+
+    /**
+     * @return 本次发出提醒的条数(按接收人聚合后的次数)
+     */
+    int syncOnce();
+}

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

@@ -26,8 +26,11 @@ import com.storlead.trade.mapper.MarketEmailsMapper;
 import com.storlead.trade.service.*;
 import com.storlead.trade.vo.*;
 import org.apache.commons.lang.time.DateUtils;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.slf4j.Logger;
 import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -62,6 +65,9 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
 
     @Resource
     private CustomerTradeService customerTradeService;
+    @Resource
+    private CustomerCompanyTradeService customerCompanyTradeService;
+
     @Resource
     @Lazy
     private MarketingCampaignSopDetailService marketingCampaignSopDetailEntityService;
@@ -79,6 +85,12 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
 
     @Resource
     private MktMailRemoteClient mktMailRemoteClient;
+    @Resource
+    private LiaisonTradeService liaisonTradeService;
+
+    @Autowired
+    @Lazy
+    private RedissonClient redissonClient;
 
 
     @Override
@@ -312,10 +324,14 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
 
 
         // 如果是开启保护 且 前端传了保护原因 -> 一并写入
-        if (Integer.valueOf(1).equals(dto.getHasProtect()) && !ObjectUtils.isEmpty(dto.getProtectReason())) {
-            wrapper.set(MarketEmailsEntity::getProtectReason, dto.getProtectReason());
+        if (Integer.valueOf(1).equals(dto.getHasProtect())) {
             wrapper.set(MarketEmailsEntity::getProtectTime, new Date());
+            if (!ObjectUtils.isEmpty(dto.getProtectReason())) {
+                wrapper.set(MarketEmailsEntity::getProtectReason, dto.getProtectReason());
+            }
         }
+
+
         // 如果是移除保护 -> 清空保护原因
         if (Integer.valueOf(0).equals(dto.getHasProtect())) {
             wrapper.set(MarketEmailsEntity::getProtectReason, null);
@@ -450,11 +466,21 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
                 if (vo.getId() == null && e.getId() != null) {
                     vo.setId(e.getId());
                 }
-                // 取最早的 firstReplyTime + 同时记录主题/emailId
+                // 取最早的 firstReplyTime
                 if (e.getFirstReplyTime() != null) {
                     if (vo.getFirstReplyTime() == null || e.getFirstReplyTime().before(vo.getFirstReplyTime())) {
                         vo.setFirstReplyTime(e.getFirstReplyTime());
-                        vo.setReplyContent(e.getFirstReplyTheme());
+                    }
+                }
+                // 取最新且非空的回复主题(bestReplyContent 聚合)
+                String theme = e.getFirstReplyTheme();
+                if (theme != null && !theme.trim().isEmpty()) {
+                    if (vo.getBestReplyContent() == null) {
+                        vo.setBestReplyContent(theme);
+                    } else if (e.getFirstReplyTime() != null
+                            && (vo.getFirstReplyTime() == null || e.getFirstReplyTime().after(vo.getFirstReplyTime()))) {
+                        // 当前记录的 firstReplyTime 比已记录的 firstReplyTime 更新时,更新 bestReplyContent
+                        vo.setBestReplyContent(theme);
                     }
                 }
                 // MAX(has_protect) / MAX(has_suspend) 语义
@@ -466,10 +492,12 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
                 }
             }
         }
-        // 默认未保护/未暂停为 0
+        // 默认未保护/未暂停为 0;bestReplyContent 写回 replyContent
         for (com.storlead.trade.vo.MarketEmailsReplyVO vo : aggMap.values()) {
             if (vo.getHasProtect() == null) vo.setHasProtect(0);
             if (vo.getHasSuspend() == null) vo.setHasSuspend(0);
+            vo.setReplyContent(vo.getBestReplyContent());
+            vo.setBestReplyContent(null);
         }
         // 按首次回复时间倒序(最近回复的排前)
         List<com.storlead.trade.vo.MarketEmailsReplyVO> voList = new java.util.ArrayList<>(aggMap.values());
@@ -483,7 +511,7 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
         // 批量回填:客户名 / 客户等级 / 核心标签
         if (!voList.isEmpty()) {
             java.util.Set<Long> customerIdSet = new java.util.LinkedHashSet<>();
-            for (com.storlead.trade.vo.MarketEmailsReplyVO v : voList) {
+            for (MarketEmailsReplyVO v : voList) {
                 if (v.getCustomerId() != null) customerIdSet.add(v.getCustomerId());
             }
             if (!customerIdSet.isEmpty()) {
@@ -520,6 +548,7 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
                         }
                         CustomerAnalysisResultEntity a = analysisMap.get(v.getCustomerId());
                         if (a != null) {
+                            v.setCustomerAnalysisResult(a);
                             v.setCustomerLevel(a.getCustomerLevel());
                             v.setCoreTags(a.getCoreTags());
                             if (a.getCustomerLevel() != null) {
@@ -995,6 +1024,41 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
                         marketEmail.setIsDelete(CommonConstant.DEL_FLAG_0);
                     }
             );
+            //查询其他营销活动关联客户的暂停和取消状态,同步写入到当前客户
+            for (MarketEmailsEntity marketEmail : marketEmailList){
+                LambdaQueryWrapper<MarketEmailsEntity> queryOtherWrapper = new LambdaQueryWrapper<>();
+                queryOtherWrapper.eq(MarketEmailsEntity::getCustomerId, marketEmail.getCustomerId());
+                queryOtherWrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+                queryOtherWrapper.ne(MarketEmailsEntity::getMarketingCampaignId, marketEmail.getMarketingCampaignId());
+                queryOtherWrapper.last("limit 1");
+                MarketEmailsEntity marketOtherEmail = this.getOne(queryOtherWrapper);
+                if (marketOtherEmail != null){
+                    marketEmail.setHasSuspend(marketOtherEmail.getHasSuspend());
+                    marketEmail.setHasProtect(marketOtherEmail.getHasProtect());
+                    marketEmail.setProtectReason(marketOtherEmail.getProtectReason());
+                    marketEmail.setProtectTime(marketOtherEmail.getProtectTime());
+                    marketEmail.setSuspendTime(marketOtherEmail.getSuspendTime());
+                }
+
+                //通过客户id写入企业关联邮箱
+                String email = "";
+                CustomerCompanyEntity customerCompany = customerCompanyTradeService.getOne(new LambdaQueryWrapper<CustomerCompanyEntity>()
+                        .eq(CustomerCompanyEntity::getCustomerId, marketEmail.getCustomerId())
+                        .eq(CustomerCompanyEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                        .last("limit 1"));
+                if (!ObjectUtils.isEmpty(customerCompany)){
+                    email = customerCompany.getEmail();
+                    if (ObjectUtils.isEmpty(email)){
+                        LiaisonEntity liaison = liaisonTradeService.getOne(new LambdaQueryWrapper<LiaisonEntity>()
+                                .eq(LiaisonEntity::getCustomerId, marketEmail.getCustomerId())
+                                .eq(LiaisonEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                                .eq(LiaisonEntity::getHasPrimaryLialison,1)
+                                .last("limit 1"));
+                        email = liaison.getEmail();
+                    }
+                    marketEmail.setRecipient(email);
+                }
+            }
             this.saveBatch(marketEmailList);
 
         }
@@ -1085,6 +1149,28 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
 
     @Override
     public void sendAgain() {
+        String lockKey = "market-emails:send-again:lock";
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean acquired = false;
+        try {
+            // 尝试获取锁,最多等 0 秒(抢不到直接放弃,避免当前执行实例被阻塞)
+            acquired = lock.tryLock(0, 2, java.util.concurrent.TimeUnit.MINUTES);
+            if (!acquired) {
+                log.info("[sendAgain] 锁未获取到,当前有其他实例在执行,跳过本次");
+                return;
+            }
+            doSendAgain(lockKey);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("[sendAgain] 获取锁被中断: {}", e.getMessage());
+        } finally {
+            if (acquired && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    private void doSendAgain(String lockKey) {
         LambdaQueryWrapper<MarketEmailsEntity> marketEmailHasFirstQueryWrapper = new LambdaQueryWrapper<>();
         marketEmailHasFirstQueryWrapper.eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
         marketEmailHasFirstQueryWrapper.eq(MarketEmailsEntity::getHasFirst, 2);
@@ -1149,6 +1235,9 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
                 Date expectSendTime = DateUtils.addMinutes(new Date(), 30);
                 newMarketEmail.setExpectSendTime(expectSendTime);
                 newMarketEmail.setHasFirst(2);
+                newMarketEmail.setHasProtect(0);
+                newMarketEmail.setHasReader(0);
+                newMarketEmail.setHasReply(0);
 
                 newMarketEmailList.add(newMarketEmail);
             }

+ 18 - 6
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignServiceImpl.java

@@ -144,9 +144,9 @@ public class MarketingCampaignServiceImpl
             Map<Long, Long> firstSentMap = countEmailsByCampaign(marketingCampaignIdList,
                     "count(*) as cnt", qw -> qw.eq("status", 1).eq("has_first", 1));
             Map<Long, Long> openedEmailMap = countEmailsByCampaign(marketingCampaignIdList,
-                    "count(*) as cnt", qw -> qw.eq("has_reader", 1));
+                    "count(*) as cnt", qw -> qw.eq("status", 1).eq("has_reader", 1));
             Map<Long, Long> repliedEmailMap = countEmailsByCampaign(marketingCampaignIdList,
-                    "count(*) as cnt", qw -> qw.eq("has_reply", 1));
+                    "count(*) as cnt", qw -> qw.eq("status", 1).eq("has_reply", 1));
             Map<Long, Long> cancelledMap = countEmailsByCampaign(marketingCampaignIdList,
                     "count(*) as cnt", qw -> qw.eq("status", -3));
             Map<Long, Long> totalEmailMap = countEmailsByCampaign(marketingCampaignIdList,
@@ -326,14 +326,16 @@ public class MarketingCampaignServiceImpl
 //        Integer sendVolume = campaign.getSendVolume();
 //        stats.setSendVolume(sendVolume);
 
+        // emailCount:计划发送总数(hasFirst=1 的邮件数,用于展示)
         Integer emailCountLong = marketEmailsService.lambdaQuery()
                 .eq(MarketEmailsEntity::getMarketingCampaignId, id)
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
-//                .eq(MarketEmailsEntity::getStatus, 1)
+                .eq(MarketEmailsEntity::getHasFirst, 1)
                 .count();
         Integer emailCount = emailCountLong == null ? 0 : emailCountLong.intValue();
         stats.setEmailCount(emailCount);
 
+        // 已发送数(status=1,所有首封邮件,与 getList/getDashboardStats 口径一致)
         Integer sentCountLong = marketEmailsService.lambdaQuery()
                 .eq(MarketEmailsEntity::getMarketingCampaignId, id)
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
@@ -342,11 +344,19 @@ public class MarketingCampaignServiceImpl
         Integer sentCount = sentCountLong == null ? 0 : sentCountLong.intValue();
         stats.setSentCount(sentCount);
 
+        // emailCountAll:全部未删邮件总数(用于 achievementRate 分母,与 sentCount 口径对齐)
+        Integer emailCountAllLong = marketEmailsService.lambdaQuery()
+                .eq(MarketEmailsEntity::getMarketingCampaignId, id)
+                .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .count();
+        int emailCountAll = emailCountAllLong == null ? 0 : emailCountAllLong.intValue();
+
 
 
         Integer openCountLong = marketEmailsService.lambdaQuery()
                 .eq(MarketEmailsEntity::getMarketingCampaignId, id)
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MarketEmailsEntity::getStatus, 1)
                 .eq(MarketEmailsEntity::getHasReader, 1)
                 .count();
         Integer openCount = openCountLong == null ? 0 : openCountLong.intValue();
@@ -355,6 +365,7 @@ public class MarketingCampaignServiceImpl
         Integer replyCountLong = marketEmailsService.lambdaQuery()
                 .eq(MarketEmailsEntity::getMarketingCampaignId, id)
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MarketEmailsEntity::getStatus, 1)
                 .eq(MarketEmailsEntity::getHasReply, 1)
                 .count();
         Integer replyCount = replyCountLong == null ? 0 : replyCountLong.intValue();
@@ -364,13 +375,14 @@ public class MarketingCampaignServiceImpl
         stats.setOpenRate(sentCount > 0 ? Math.round(openCount * 100f / sentCount) : 0);
         // 回复率 = replyCount / sentCount
         stats.setReplyRate(sentCount > 0 ? Math.round(replyCount * 100f / sentCount) : 0);
-        // 达成率 = openCountLong / emailCount
-        stats.setAchievementRate(sentCount > 0 ? Math.round(sentCount * 100f / emailCount) : 0);
+        // 达成率 = 已发送数 / 全部未删邮件数(与 getList sentRate 口径一致)
+        stats.setAchievementRate(emailCountAll > 0 ? Math.round(sentCount * 100f / emailCountAll) : 0);
 
         // 已打开邮件的客户数(distinct customer_id where hasReader=1)
         List<MarketEmailsEntity> openedEmailList = marketEmailsService.lambdaQuery()
                 .eq(MarketEmailsEntity::getMarketingCampaignId, id)
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MarketEmailsEntity::getStatus, 1)
                 .eq(MarketEmailsEntity::getHasReader, 1)
                 .isNotNull(MarketEmailsEntity::getCustomerId)
                 .select(MarketEmailsEntity::getCustomerId)
@@ -707,7 +719,7 @@ public class MarketingCampaignServiceImpl
                 .eq(MarketEmailsEntity::getHasReader, 1)
                 .count();
 
-        // 本月回复数
+        // 本月回复数(仅已发送邮件中有回复的)
         Integer replyCount = marketEmailsService.lambdaQuery()
                 .eq(MarketEmailsEntity::getStatus, 1)
                 .eq(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_0)

+ 19 - 5
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketingCampaignSopDetailServiceImpl.java

@@ -50,6 +50,19 @@ public class MarketingCampaignSopDetailServiceImpl
         if (ObjectUtils.isEmpty(dto.getId())) {
             return Result.error("id不能为空");
         }
+
+        //先判断这个sopDetail中是否有关联已发送的邮件,如果有,不能进行修改
+        LambdaQueryWrapper<MarketEmailsEntity> marketEmailsEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
+        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getMarketingCampaignSopDetailId, dto.getId());
+        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getMarketingCampaignId, dto.getMarketingCampaignId());
+        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getStatus, 1);
+        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getIsDelete, 0);
+        List<MarketEmailsEntity> marketEmailsEntityList = marketEmailsService.list(marketEmailsEntityLambdaQueryWrapper);
+        if (!CollectionUtils.isEmpty(marketEmailsEntityList)) {
+            return Result.error("存在已发送的邮件,不能进行修改");
+        }
+
+
         MarketingCampaignSopDetailEntity entity = new MarketingCampaignSopDetailEntity();
         BeanUtils.copyProperties(dto, entity);
         boolean isOk = this.updateById(entity);
@@ -58,16 +71,17 @@ public class MarketingCampaignSopDetailServiceImpl
         }
         if (!ObjectUtils.isEmpty(dto.getEmailContent())){
             //写入每个客户的邮件内容
-            this.updateEmailContent(dto.getId(),dto.getEmailSubject(), dto.getEmailContent());
+            this.updateEmailContent(dto.getId(),dto.getEmailSubject(), dto.getEmailContent(), dto.getMarketingCampaignId());
         }
         return Result.result(entity);
     }
 
-    private void updateEmailContent(Long id, String emailSubject, String emailContent) {
+    private void updateEmailContent(Long id, String emailSubject, String emailContent, Long marketingCampaignId) {
         //查询营销活动关联的所有客户
 //        MarketingCampaignSopDetailEntity entity = this.getById(id);
         LambdaQueryWrapper<MarketEmailsEntity> marketEmailsEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
         marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getMarketingCampaignSopDetailId, id);
+        marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getMarketingCampaignId, marketingCampaignId);
         marketEmailsEntityLambdaQueryWrapper.eq(MarketEmailsEntity::getIsDelete, 0);
         List<MarketEmailsEntity> marketEmailsEntityList = marketEmailsService.list(marketEmailsEntityLambdaQueryWrapper);
 
@@ -149,9 +163,9 @@ public class MarketingCampaignSopDetailServiceImpl
                 marketEmailsEntity.setSubject(emailSubjectNew);
 
             }
-            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());
-            }
+//            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);

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

@@ -0,0 +1,16 @@
+package com.storlead.trade.service.impl;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.storlead.framework.common.constant.DSConstants;
+import com.storlead.trade.entity.MktMailInboxCursorEntity;
+import com.storlead.trade.mapper.MktMailInboxCursorMapper;
+import com.storlead.trade.service.MktMailInboxCursorService;
+import com.storlead.trade.service.impl.support.TradeDataSourceServiceImpl;
+import org.springframework.stereotype.Service;
+
+@Service
+@DS(DSConstants.DATASOURCE_TRADE)
+public class MktMailInboxCursorServiceImpl
+        extends TradeDataSourceServiceImpl<MktMailInboxCursorMapper, MktMailInboxCursorEntity>
+        implements MktMailInboxCursorService {
+}

+ 177 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MktMailNewNotifyServiceImpl.java

@@ -0,0 +1,177 @@
+package com.storlead.trade.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.framework.common.constant.DSConstants;
+import com.storlead.mail.pojo.entity.MktMailMessageEntity;
+import com.storlead.mail.service.MktMailMessageService;
+import com.storlead.trade.entity.MktMailInboxCursorEntity;
+import com.storlead.trade.enums.TradeMessageTypeEnum;
+import com.storlead.trade.service.MktMailInboxCursorService;
+import com.storlead.trade.service.MktMailNewNotifyService;
+import com.storlead.trade.service.TradeMessageAssembleService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 用游标表记录收件箱未读最大 id,按 id 增量判断是否有新邮件后发提醒。
+ */
+@Slf4j
+@Service
+@DS(DSConstants.DATASOURCE_TRADE)
+public class MktMailNewNotifyServiceImpl implements MktMailNewNotifyService {
+
+    private static final int BATCH_LIMIT = 200;
+    private static final int MAX_PAGES = 50;
+
+    @Resource
+    private MktMailMessageService mktMailMessageService;
+
+    @Resource
+    private MktMailInboxCursorService mktMailInboxCursorService;
+
+    @Resource
+    private TradeMessageAssembleService tradeMessageAssembleService;
+
+    @Override
+    public synchronized int syncOnce() {
+        MktMailInboxCursorEntity cursor = currentCursor();
+        if (cursor == null) {
+            initCursorWithoutNotify();
+            return 0;
+        }
+        Long lastId = cursor.getLastMailId() == null ? 0L : cursor.getLastMailId();
+        int notified = 0;
+        Long maxId = lastId;
+        for (int page = 0; page < MAX_PAGES; page++) {
+            List<MktMailMessageEntity> newMails;
+            try {
+                newMails = mktMailMessageService.listNewInboxMails(lastId, BATCH_LIMIT);
+            } catch (Exception e) {
+                log.error("查询营销收件箱新邮件失败 lastId={}", lastId, e);
+                break;
+            }
+            if (CollectionUtils.isEmpty(newMails)) {
+                break;
+            }
+            Long batchLastId = newMails.get(newMails.size() - 1).getId();
+            notified += notifyByOwner(newMails);
+            if (batchLastId != null) {
+                lastId = batchLastId;
+                maxId = batchLastId;
+            }
+            if (newMails.size() < BATCH_LIMIT) {
+                break;
+            }
+        }
+        if (maxId != null && maxId > (cursor.getLastMailId() == null ? 0L : cursor.getLastMailId())) {
+            cursor.setLastMailId(maxId);
+            mktMailInboxCursorService.updateById(cursor);
+        }
+        return notified;
+    }
+
+    private MktMailInboxCursorEntity currentCursor() {
+        return mktMailInboxCursorService.getOne(new LambdaQueryWrapper<MktMailInboxCursorEntity>()
+                .eq(MktMailInboxCursorEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .orderByAsc(MktMailInboxCursorEntity::getId)
+                .last("limit 1"));
+    }
+
+    /**
+     * 游标表无记录:写入当前未读最大 id,本次不提醒。
+     */
+    private void initCursorWithoutNotify() {
+        Long maxId = 0L;
+        try {
+            Long inboxMax = mktMailMessageService.maxInboxUnreadId();
+            if (inboxMax != null) {
+                maxId = inboxMax;
+            }
+        } catch (Exception e) {
+            log.error("初始化营销收件箱游标时查询最大未读 id 失败", e);
+            return;
+        }
+        MktMailInboxCursorEntity cursor = new MktMailInboxCursorEntity();
+        cursor.setLastMailId(maxId);
+        cursor.setIsDelete(CommonConstant.DEL_FLAG_0);
+        mktMailInboxCursorService.save(cursor);
+        log.info("营销收件箱新邮件游标首次初始化 lastMailId={},本次不提醒", maxId);
+    }
+
+    private int notifyByOwner(List<MktMailMessageEntity> newMails) {
+        Map<Long, List<MktMailMessageEntity>> byOwner = newMails.stream()
+                .filter(mail -> resolveOwner(mail) != null)
+                .collect(Collectors.groupingBy(MktMailNewNotifyServiceImpl::resolveOwner));
+        if (byOwner.isEmpty()) {
+            return 0;
+        }
+        int sent = 0;
+        for (Map.Entry<Long, List<MktMailMessageEntity>> entry : byOwner.entrySet()) {
+            List<MktMailMessageEntity> ownerMails = entry.getValue();
+            Map<String, Object> payload = new HashMap<>();
+            payload.put("messageContent", buildContent(ownerMails));
+            payload.put("callbackUrl", "/marketing");
+            payload.put("id", "/marketing");
+            Set<Long> toUserIds = new HashSet<>();
+            toUserIds.add(entry.getKey());
+            try {
+                tradeMessageAssembleService.sendEventMessage(
+                        TradeMessageTypeEnum.MKT_REPLY_NEW_MAIL.getCode(),
+                        "MKT_REPLY_NEW_MAIL",
+                        payload,
+                        toUserIds);
+                sent++;
+            } catch (Exception e) {
+                log.error("发送营销新邮件提醒失败 ownerBy={} count={}", entry.getKey(), ownerMails.size(), e);
+            }
+        }
+        return sent;
+    }
+
+    private static String buildContent(List<MktMailMessageEntity> ownerMails) {
+        Set<String> froms = ownerMails.stream()
+                .map(MktMailMessageEntity::getFromAddr)
+                .filter(StrUtil::isNotBlank)
+                .collect(Collectors.toCollection(HashSet::new));
+        int count = ownerMails.size();
+        if (froms.isEmpty()) {
+            return "您有" + count + "封新邮件,请前往查看!";
+        }
+        StringBuilder sb = new StringBuilder();
+        int i = 0;
+        for (String from : froms) {
+            if (i >= 3) {
+                break;
+            }
+            if (sb.length() > 0) {
+                sb.append("、");
+            }
+            sb.append("【").append(from).append("】");
+            i++;
+        }
+        String more = froms.size() > 3 ? "等" : "";
+        return "您有来自" + sb + more + "的" + count + "封新邮件,请前往查看!";
+    }
+
+    private static Long resolveOwner(MktMailMessageEntity mail) {
+        if (mail.getOwnerBy() != null && mail.getOwnerBy() > 0) {
+            return mail.getOwnerBy();
+        }
+        if (mail.getCreateBy() != null && mail.getCreateBy() > 0) {
+            return mail.getCreateBy();
+        }
+        return null;
+    }
+}

+ 1 - 1
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/TradeMessageAssembleServiceImpl.java

@@ -70,6 +70,6 @@ public class TradeMessageAssembleServiceImpl implements TradeMessageAssembleServ
     @Override
     public void sendCustomizeMessage(String eventCode, Map<String, Object> payload,
                                      Set<Long> toUserIds, String... channels) {
-        sendEventMessage(TradeMessageTypeEnum.CUSTOMIZE_EXAMPLE, eventCode, payload, toUserIds, channels);
+        sendEventMessage(TradeMessageTypeEnum.MKT_REPLY_NEW_MAIL, eventCode, payload, toUserIds, channels);
     }
 }

+ 1 - 1
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/task/MarketStartOrEndTask.java

@@ -18,7 +18,7 @@ public class MarketStartOrEndTask {
     @Resource
     private MarketingCampaignService marketingCampaignService;
 
-    @Scheduled(cron = "0 37 9 * * ?")
+    @Scheduled(cron = "0 0 16 * * ?")
     public void dispatch() {
         long start = System.currentTimeMillis();
         log.info("营销活动开始/结束状态同步任务开始");

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

@@ -0,0 +1,33 @@
+package com.storlead.trade.task;
+
+import com.storlead.trade.service.MktMailNewNotifyService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 定时查询营销收件箱(mkt_mail_message)是否有新邮件,有则发消息提醒。
+ */
+@Slf4j
+@Component
+public class MktMailNewNotifyTask {
+
+    @Resource
+    private MktMailNewNotifyService mktMailNewNotifyService;
+
+    @Scheduled(cron = "0 */2 * * * ?")
+    public void sync() {
+        long start = System.currentTimeMillis();
+        try {
+            int notified = mktMailNewNotifyService.syncOnce();
+            if (notified > 0) {
+                log.info("营销收件箱新邮件提醒完成 notified={} cost={}ms",
+                        notified, System.currentTimeMillis() - start);
+            }
+        } catch (Exception e) {
+            log.error("营销收件箱新邮件提醒失败", e);
+        }
+    }
+}

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

@@ -3,6 +3,7 @@ package com.storlead.trade.vo;
 import com.alibaba.fastjson.annotation.JSONField;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
+import com.storlead.trade.entity.CustomerAnalysisResultEntity;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
@@ -49,6 +50,9 @@ public class MarketEmailsReplyVO implements Serializable {
     @ApiModelProperty(value = "核心标签(AI 给客户打的标签,逗号分隔)")
     private String coreTags;
 
+    @ApiModelProperty(value = "客户AI分析结果(最新一条,一对一关联 customer_analysis_result)")
+    private CustomerAnalysisResultEntity customerAnalysisResult;
+
     @ApiModelProperty(value = "回复次数(同 recipient 下的邮件回复记录数)")
     private Long replyCount;
 
@@ -61,6 +65,9 @@ public class MarketEmailsReplyVO implements Serializable {
     @ApiModelProperty(value = "回复内容(复用 first_reply_theme 字段,DB 未单独建 reply_content)")
     private String replyContent;
 
+    @ApiModelProperty(value = "临时字段:用于聚合时记录最佳回复主题(最新且非空),聚合结束后写入 replyContent,不暴露给前端")
+    private String bestReplyContent;
+
     @ApiModelProperty(value = "是否保护(0未保护、1保护)")
     private Integer hasProtect;
 

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

@@ -48,7 +48,7 @@
             <div v-for="account in emailList" :key="account.id" class="email-account-item" :class="{selected: selectedEmailAccount?.id === account.id}" @click="getDetails(account)">
               <div class="account-icon"><i class="fas fa-envelope"></i></div>
               <div class="account-info">
-                <div class="account-email">{{ account.mailAccount }}</div>
+                <div class="account-email">{{ account.emailAddress }}</div>
                 <div class="account-name">销售邮箱</div>
                 <div class="account-stats">
                   <span><i class="fas fa-paper-plane"></i> 今日: 45</span>
@@ -144,7 +144,7 @@
       <div class="modal settings-modal extra-wide">
         <div class="modal-header">
           <div class="modal-title">
-            <i class="fas fa-envelope"></i> {{ selectedEmailAccount?.mailAccount }} - 详细配置
+            <i class="fas fa-envelope"></i> {{ selectedEmailAccount?.emailAddress }} - 详细配置
           </div>
           <button class="modal-close" @click="showEmailDetailModal = false"><i class="fas fa-times"></i></button>
         </div>
@@ -283,7 +283,7 @@
     const portData = ref()
     const getDetails = (data) => {
       selectedEmailAccount.value = data
-      getMailsPort(data.mailAccount).then(res => {
+      getMailsPort(data.emailAddress).then(res => {
           if(res.success && res.result) {
               portData.value = res.result
           }

+ 1 - 1
ui/smarttrade-platform/src/components/layout/TopBar.vue

@@ -825,7 +825,7 @@ const notificationTabs = ref([])
 
 const filteredNotifications = computed(() => {
   if (activeTab.value === 'all') return notifications.value
-  return notifications.value.filter(n => n.type === activeTab.value)
+  return notifications.value.filter(n => n.messageType === activeTab.value)
 })
 
 // Methods

+ 4 - 3
ui/smarttrade-platform/src/components/marketing/aiGenerator.vue

@@ -108,7 +108,7 @@
                             <el-button type="primary" @click="aiShow = !aiShow">AI润色</el-button>
                         </div> -->
                     </div>
-                    <div class="file-div" v-loading="fileLoading">
+                    <!-- <div class="file-div" v-loading="fileLoading">
                         <div class="file-header">
                             <span>附件列表</span>
                             <span>{{ (form.fileIds.length || 0) + (form.oldfileIds?.length || 0)}}个文件 已上传{{ fileSize }}K</span>
@@ -127,7 +127,7 @@
                         >
                             <p style="margin-top: 50px" v-if="(form.fileIds.length + form.oldfileIds?.length) == 0">点击上传</p>
                         </el-upload>
-                    </div>
+                    </div> -->
                 </div> 
             </div>
           </div>
@@ -401,12 +401,13 @@
             id: typeData.value.filter(item => item.name == aiGenerator.value.inputs.scene)[0].id,
             emailContent: emailContent.value,
             emailSubject: emailSubject.value,
+            marketingCampaignId: marketingData.value.id
         }
         saveGenerateEmail(params).then(res => {
             ElMessage.success('保存成功')
             showAIModal.value = false
         }).catch(err => {
-            ElMessage.error('保存失败')
+            ElMessage.error(err.message || '保存失败')
         })
     }
 

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

@@ -105,7 +105,7 @@
           ElMessage.success('操作成功')
           emits('refresh')
         }).catch(err => {
-          ElMessage.error('操作失败')
+          ElMessage.error(err.message ||'操作失败')
         })
       })
     }

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

@@ -115,7 +115,7 @@
           ElMessage.success('操作成功')
           emits('refresh')
         }).catch(err => {
-          ElMessage.error('操作失败')
+          ElMessage.error(err.message || '操作失败')
         })
       })
     }
@@ -139,7 +139,7 @@
           ElMessage.success('操作成功')
           emits('refresh')
         }).catch(err => {
-          ElMessage.error('操作失败')
+          ElMessage.error(err.message || '操作失败')
         })
       })
     }

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

@@ -114,7 +114,7 @@
           ElMessage.success('操作成功')
           emits('refresh')
         }).catch(err => {
-          ElMessage.error('操作失败')
+          ElMessage.error(err.message ||'操作失败')
         })
       })
     }
@@ -138,7 +138,7 @@
           ElMessage.success('操作成功')
           emits('refresh')
         }).catch(err => {
-          ElMessage.error('操作失败')
+          ElMessage.error(err.message || '操作失败')
         })
       })
     }

+ 1 - 1
ui/smarttrade-platform/src/views/MarketingView.vue

@@ -134,7 +134,7 @@
               <img src="../assets/images/email-list.svg" />{{ campaign.emailCountStats }}
             </button>
           </div>
-          <button class="btn btn-secondary btn-sm" @click="changeStatus(campaign)" :title="campaign.status == 1 ? '暂停' : '开始'">
+          <button v-if="campaign.status != 3" class="btn btn-secondary btn-sm" @click="changeStatus(campaign)" :title="campaign.status == 1 ? '暂停' : '开始'">
             <img v-if="campaign.status == 1" src="../assets/images/marketing-pause.svg" />
             <img v-else src="../assets/images/marketing-start.svg" />
           </button>

+ 2 - 2
ui/smarttrade-platform/src/views/emails/edit.vue

@@ -43,7 +43,7 @@
                             <el-button type="primary" @click="aiShow = !aiShow">AI润色</el-button>
                         </div> -->
                     </div>
-                    <div class="file-div" v-loading="fileLoading">
+                    <!-- <div class="file-div" v-loading="fileLoading">
                         <div class="file-header">
                             <span>附件列表</span>
                             <span>{{ (form.fileIds.length || 0) + (form.oldfileIds?.length || 0)}}个文件 已上传{{ fileSize }}K</span>
@@ -62,7 +62,7 @@
                         >
                             <p style="margin-top: 50px" v-if="(form.fileIds.length + form.oldfileIds?.length) == 0">点击上传</p>
                         </el-upload>
-                    </div>
+                    </div> -->
                 </div>
             </div>
         </div>