Browse Source

邮件打开次数和最后打开时间

chenkq 3 tuần trước cách đây
mục cha
commit
98278755d3

+ 10 - 5
storlead-mail/README.md

@@ -76,9 +76,14 @@ API:
 
 | 事件 | 更新字段 |
 |------|----------|
-| 草稿/发送落库 | `ensureStatus` 初始化行 |
-| SMTP 成功/失败 | `send_status` = 1 / 2 |
-| 像素打开 | `opened` / `first_open_time`,并同步 `is_read` / `first_read_time` |
-| 收信绑定回复 | 原信 `replied` / `first_reply_time` |
+| 草稿/发送落库 | `ensureStatus` 初始化行,`status_updated_at=now`,`synced_at=null` |
+| SMTP 成功/失败 | `send_status` = 1 / 2,刷新 `status_updated_at` |
+| 像素打开 | `opened=1`,`open_count+1`,`first_open_time`(仅首次),`last_open_time=now`,同步 `is_read` / `first_read_time`,刷新 `status_updated_at` |
+| 收信绑定回复 | 原信 `replied` / `first_reply_time`,刷新 `status_updated_at` |
+| 业务同步完成 | 回写 `synced_at=now`(不改 `status_updated_at`) |
 
-任意变更刷新 `status_updated_at`,供第三方按该字段增量拉取。
+- `status_updated_at`:业务状态变更时间(打开/回复/发送态等)
+- `synced_at`:业务侧最后同步时间
+- 待同步条件:`synced_at IS NULL OR synced_at < status_updated_at`
+
+存量升级执行:`storlead-mail-common/src/main/resources/sql/mkt_mail_status_add_synced_at.sql`

+ 6 - 2
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_recreate.sql

@@ -88,12 +88,15 @@ CREATE TABLE mkt_mail_status (
     mail_id           bigint       NOT NULL COMMENT '邮件ID,对应 mkt_mail_message.id',
     send_status       tinyint      NOT NULL DEFAULT 0 COMMENT '发送状态:-2草稿 -1定时 0发送中 1成功 2失败',
     opened            tinyint      NOT NULL DEFAULT 0 COMMENT '是否已打开(像素):0否 1是',
+    open_count        int          NOT NULL DEFAULT 0 COMMENT '打开次数(每次像素回调+1)',
     first_open_time   datetime     NULL COMMENT '首次打开时间',
+    last_open_time    datetime     NULL COMMENT '最近打开时间',
     is_read           tinyint      NOT NULL DEFAULT 0 COMMENT '是否已读:0否 1是',
     first_read_time   datetime     NULL COMMENT '首次已读时间',
     replied           tinyint      NOT NULL DEFAULT 0 COMMENT '是否已回复:0否 1是',
     first_reply_time  datetime     NULL COMMENT '首次回复时间',
-    status_updated_at datetime     NOT NULL COMMENT '状态最后更新时间(第三方增量游标)',
+    status_updated_at datetime     NOT NULL COMMENT '业务状态变更时间(打开/回复/发送态等变更时刷新)',
+    synced_at         datetime     NULL COMMENT '业务侧最后同步时间;null或小于status_updated_at表示待同步',
     owner_by          bigint       NULL COMMENT '数据归属人',
     create_by         bigint       NULL COMMENT '创建人',
     update_by         bigint       NULL COMMENT '最后更新人',
@@ -104,5 +107,6 @@ CREATE TABLE mkt_mail_status (
     sort              int          NULL COMMENT '排序号',
     PRIMARY KEY (id),
     UNIQUE KEY uk_mkt_mail_status_mail (mail_id),
-    KEY idx_mkt_status_updated (status_updated_at, id)
+    KEY idx_mkt_status_updated (status_updated_at, id),
+    KEY idx_mkt_status_synced (synced_at, status_updated_at, id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营销邮件状态(供第三方增量同步)';

+ 6 - 2
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_status.sql

@@ -5,13 +5,16 @@ CREATE TABLE IF NOT EXISTS mkt_mail_status (
 
     send_status       tinyint      NOT NULL DEFAULT 0 COMMENT '发送状态:-2草稿 -1定时 0发送中 1成功 2失败',
     opened            tinyint      NOT NULL DEFAULT 0 COMMENT '是否已打开(像素):0否 1是',
+    open_count        int          NOT NULL DEFAULT 0 COMMENT '打开次数(每次像素回调+1)',
     first_open_time   datetime     NULL COMMENT '首次打开时间',
+    last_open_time    datetime     NULL COMMENT '最近打开时间',
     is_read           tinyint      NOT NULL DEFAULT 0 COMMENT '是否已读:0否 1是',
     first_read_time   datetime     NULL COMMENT '首次已读时间',
     replied           tinyint      NOT NULL DEFAULT 0 COMMENT '是否已回复:0否 1是',
     first_reply_time  datetime     NULL COMMENT '首次回复时间',
 
-    status_updated_at datetime     NOT NULL COMMENT '状态最后更新时间(第三方增量游标)',
+    status_updated_at datetime     NOT NULL COMMENT '业务状态变更时间(打开/回复/发送态等变更时刷新)',
+    synced_at         datetime     NULL COMMENT '业务侧最后同步时间;null或小于status_updated_at表示待同步',
 
     owner_by          bigint       NULL COMMENT '数据归属人',
     create_by         bigint       NULL COMMENT '创建人',
@@ -24,5 +27,6 @@ CREATE TABLE IF NOT EXISTS mkt_mail_status (
 
     PRIMARY KEY (id),
     UNIQUE KEY uk_mkt_mail_status_mail (mail_id),
-    KEY idx_mkt_status_updated (status_updated_at, id)
+    KEY idx_mkt_status_updated (status_updated_at, id),
+    KEY idx_mkt_status_synced (synced_at, status_updated_at, id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营销邮件状态(供第三方增量同步)';

+ 17 - 0
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_status_add_synced_at.sql

@@ -0,0 +1,17 @@
+-- 存量库升级:明确 status_updated_at 语义,增加同步时间 / 打开次数 / 最近打开时间
+-- 待同步条件:synced_at IS NULL OR synced_at < status_updated_at
+
+ALTER TABLE mkt_mail_status
+    MODIFY COLUMN status_updated_at datetime NOT NULL COMMENT '业务状态变更时间(打开/回复/发送态等变更时刷新)';
+
+ALTER TABLE mkt_mail_status
+    ADD COLUMN open_count int NOT NULL DEFAULT 0 COMMENT '打开次数(每次像素回调+1)' AFTER opened;
+
+ALTER TABLE mkt_mail_status
+    ADD COLUMN last_open_time datetime NULL COMMENT '最近打开时间' AFTER first_open_time;
+
+ALTER TABLE mkt_mail_status
+    ADD COLUMN synced_at datetime NULL COMMENT '业务侧最后同步时间;null或小于status_updated_at表示待同步' AFTER status_updated_at;
+
+-- 索引已存在会报 Duplicate key name,可忽略
+CREATE INDEX idx_mkt_status_synced ON mkt_mail_status (synced_at, status_updated_at, id);

+ 37 - 2
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailStatusEntity.java

@@ -1,20 +1,25 @@
 package com.storlead.sales.mail.mkt.entity;
 
+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.entity.SysBaseField;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
 
 import java.util.Date;
 
 /**
- * 营销邮件状态快照(供第三方增量同步)。
+ * 营销邮件状态快照。
+ * status_updated_at = 业务状态变更时间;synced_at = 业务侧同步时间。
+ * 待同步:synced_at 为空或小于 status_updated_at。
  */
 @Data
 @EqualsAndHashCode(callSuper = true)
@@ -40,16 +45,33 @@ public class MktMailStatusEntity extends SysBaseField {
     @TableField("opened")
     private Integer opened;
 
+    @ApiModelProperty("打开次数(每次像素回调+1)")
+    @TableField("open_count")
+    private Integer openCount;
+
     @ApiModelProperty("首次打开时间")
     @TableField("first_open_time")
+    @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
+    @JSONField(format ="yyyy-MM-dd HH:mm:ss")
     private Date firstOpenTime;
 
+    @ApiModelProperty("最近打开时间")
+    @TableField("last_open_time")
+    @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
+    @JSONField(format ="yyyy-MM-dd HH:mm:ss")
+    private Date lastOpenTime;
+
     @ApiModelProperty("是否已读")
     @TableField("is_read")
     private Integer isRead;
 
     @ApiModelProperty("首次已读时间")
     @TableField("first_read_time")
+    @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
+    @JSONField(format ="yyyy-MM-dd HH:mm:ss")
     private Date firstReadTime;
 
     @ApiModelProperty("是否已回复")
@@ -57,10 +79,23 @@ public class MktMailStatusEntity extends SysBaseField {
     private Integer replied;
 
     @ApiModelProperty("首次回复时间")
+    @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("状态最后更新时间(第三方增量游标)")
+    @ApiModelProperty("业务状态变更时间(打开/回复/发送态等变更时刷新)")
+    @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("status_updated_at")
     private Date statusUpdatedAt;
+
+    @ApiModelProperty("业务侧最后同步时间;null 或小于 status_updated_at 表示待同步")
+    @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("synced_at")
+    private Date syncedAt;
 }

+ 13 - 1
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailStatusService.java

@@ -4,6 +4,8 @@ import com.storlead.framework.mybatis.service.MyBaseService;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailStatusEntity;
 
+import java.util.Collection;
+
 /**
  * 营销邮件状态(mkt_mail_status)埋点写入。
  */
@@ -20,7 +22,7 @@ public interface MktMailStatusService extends MyBaseService<MktMailStatusEntity>
     void markSendStatus(Long mailId, Integer sendStatus);
 
     /**
-     * 像素打开:更新 opened / first_open_time,并同步 is_read / first_read_time。
+     * 像素打开:更新 opened / open_count / first_open_time / last_open_time,并同步 is_read / first_read_time。
      */
     void markOpened(Long mailId);
 
@@ -28,4 +30,14 @@ public interface MktMailStatusService extends MyBaseService<MktMailStatusEntity>
      * 收到针对原信的回复:更新 replied / first_reply_time(仅首次)。
      */
     void markReplied(Long mailId);
+
+    /**
+     * 业务侧同步完成后回写同步时间(synced_at)。
+     */
+    void markSynced(Long mailId);
+
+    /**
+     * 批量回写同步时间。
+     */
+    void markSynced(Collection<Long> mailIds);
 }

+ 48 - 1
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailStatusServiceImpl.java

@@ -11,11 +11,13 @@ import com.storlead.sales.mail.mkt.service.MktMailStatusService;
 import lombok.extern.log4j.Log4j2;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 
+import java.util.Collection;
 import java.util.Date;
 
 /**
- * mkt_mail_status 埋点:发送态 / 打开 / 回复。
+ * mkt_mail_status 埋点:发送态 / 打开 / 回复;status_updated_at 为业务变更时间,synced_at 为业务同步时间。
  */
 @Log4j2
 @Service
@@ -35,12 +37,18 @@ public class MktMailStatusServiceImpl extends MyBaseServiceImpl<MktMailStatusMap
             return;
         }
 
+        Date now = new Date();
         MktMailStatusEntity row = new MktMailStatusEntity();
         row.setMailId(mail.getId());
         row.setSendStatus(mail.getStatus() == null ? 0 : mail.getStatus());
         row.setOpened(0);
+        row.setOpenCount(0);
         row.setIsRead(0);
         row.setReplied(0);
+        row.setStatusUpdatedAt(now);
+        row.setSyncedAt(null);
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
         row.setOwnerBy(mail.getOwnerBy());
         row.setCreateBy(mail.getCreateBy() == null ? mail.getOwnerBy() : mail.getCreateBy());
         row.setIsDelete(CommonConstant.DEL_FLAG_0);
@@ -66,6 +74,7 @@ public class MktMailStatusServiceImpl extends MyBaseServiceImpl<MktMailStatusMap
         update.eq(MktMailStatusEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
         update.set(MktMailStatusEntity::getSendStatus, sendStatus);
         update.set(MktMailStatusEntity::getStatusUpdatedAt, now);
+        update.set(MktMailStatusEntity::getUpdateTime, now);
         this.update(update);
     }
 
@@ -81,9 +90,12 @@ public class MktMailStatusServiceImpl extends MyBaseServiceImpl<MktMailStatusMap
         update.eq(MktMailStatusEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
         update.set(MktMailStatusEntity::getOpened, 1);
         update.set(MktMailStatusEntity::getIsRead, 1);
+        update.setSql("open_count = IFNULL(open_count, 0) + 1");
         update.setSql("first_open_time = IFNULL(first_open_time, NOW())");
+        update.set(MktMailStatusEntity::getLastOpenTime, now);
         update.setSql("first_read_time = IFNULL(first_read_time, NOW())");
         update.set(MktMailStatusEntity::getStatusUpdatedAt, now);
+        update.set(MktMailStatusEntity::getUpdateTime, now);
         this.update(update);
     }
 
@@ -100,6 +112,35 @@ public class MktMailStatusServiceImpl extends MyBaseServiceImpl<MktMailStatusMap
         update.set(MktMailStatusEntity::getReplied, 1);
         update.setSql("first_reply_time = IFNULL(first_reply_time, NOW())");
         update.set(MktMailStatusEntity::getStatusUpdatedAt, now);
+        update.set(MktMailStatusEntity::getUpdateTime, now);
+        this.update(update);
+    }
+
+    @Override
+    public void markSynced(Long mailId) {
+        if (mailId == null) {
+            return;
+        }
+        Date now = new Date();
+        LambdaUpdateWrapper<MktMailStatusEntity> update = new LambdaUpdateWrapper<>();
+        update.eq(MktMailStatusEntity::getMailId, mailId);
+        update.eq(MktMailStatusEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        update.set(MktMailStatusEntity::getSyncedAt, now);
+        update.set(MktMailStatusEntity::getUpdateTime, now);
+        this.update(update);
+    }
+
+    @Override
+    public void markSynced(Collection<Long> mailIds) {
+        if (CollectionUtils.isEmpty(mailIds)) {
+            return;
+        }
+        Date now = new Date();
+        LambdaUpdateWrapper<MktMailStatusEntity> update = new LambdaUpdateWrapper<>();
+        update.in(MktMailStatusEntity::getMailId, mailIds);
+        update.eq(MktMailStatusEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        update.set(MktMailStatusEntity::getSyncedAt, now);
+        update.set(MktMailStatusEntity::getUpdateTime, now);
         this.update(update);
     }
 
@@ -114,12 +155,18 @@ public class MktMailStatusServiceImpl extends MyBaseServiceImpl<MktMailStatusMap
         if (getByMailId(mailId) != null) {
             return;
         }
+        Date now = new Date();
         MktMailStatusEntity row = new MktMailStatusEntity();
         row.setMailId(mailId);
         row.setSendStatus(0);
         row.setOpened(0);
+        row.setOpenCount(0);
         row.setIsRead(0);
         row.setReplied(0);
+        row.setStatusUpdatedAt(now);
+        row.setSyncedAt(null);
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
         row.setIsDelete(CommonConstant.DEL_FLAG_0);
         row.setEnabled(Boolean.TRUE);
         try {