Selaa lähdekoodia

邮件服务接口增加和调整,业务拆分

chenkq 3 viikkoa sitten
vanhempi
sitoutus
0a38eee6d7
22 muutettua tiedostoa jossa 791 lisäystä ja 99 poistoa
  1. 2 2
      storlead-api/src/main/java/com/storlead/mail/crm/MaiAttachmentApiController.java
  2. 2 1
      storlead-api/src/main/java/com/storlead/mail/crm/MailApiController.java
  3. 173 5
      storlead-api/src/main/java/com/storlead/mail/mkt/MktMailApiController.java
  4. 2 0
      storlead-api/src/main/resources/application.yml
  5. 13 2
      storlead-mail/README.md
  6. 5 1
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/entity/MailTempAttachmentEntity.java
  7. 2 2
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/mapper/MailTempAttachmentMapper.java
  8. 7 12
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/MailTempAttachmentService.java
  9. 18 11
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/impl/MailTempAttachmentServiceImpl.java
  10. 4 3
      storlead-mail/storlead-mail-common/src/main/resources/mapper/MailTempAttachmentMapper.xml
  11. 30 1
      storlead-mail/storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql
  12. 2 0
      storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/impl/EmailsServiceImpl.java
  13. 2 2
      storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/support/MailOwnershipGuard.java
  14. 55 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailAttachmentEntity.java
  15. 9 1
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailMessageEntity.java
  16. 7 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/mapper/MktMailAttachmentMapper.java
  17. 18 2
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktSendMailDTO.java
  18. 22 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailAttachmentService.java
  19. 11 5
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailMessageService.java
  20. 65 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailAttachmentServiceImpl.java
  21. 325 49
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java
  22. 17 0
      storlead-mail/storlead-mail-mkt/src/main/resources/mapper/MktMailAttachmentMapper.xml

+ 2 - 2
storlead-api/src/main/java/com/storlead/mail/crm/MaiAttachmentApiController.java

@@ -4,10 +4,10 @@ import com.alibaba.fastjson.JSONObject;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.common.util.DateUtils;
 import com.storlead.framework.util.LoginUserUtil;
-import com.storlead.sales.mail.crm.entity.MailTempAttachmentEntity;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
 import com.storlead.sales.mail.common.properties.MailFileProperties;
-import com.storlead.sales.mail.crm.service.MailTempAttachmentService;
+import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
 import com.storlead.sales.mail.crm.support.MailOwnershipGuard;
 import com.storlead.sales.mail.crm.util.FileHelper;

+ 2 - 1
storlead-api/src/main/java/com/storlead/mail/crm/MailApiController.java

@@ -3,8 +3,10 @@ package com.storlead.mail.crm;
 import com.storlead.mail.common.EmailServiceChecker;
 
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
 import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
 import com.storlead.sales.mail.common.service.MailServerPortConfigService;
+import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 import com.storlead.sales.mail.common.entity.MailServerPortConfigEntity;
 
 import cn.hutool.core.util.StrUtil;
@@ -90,7 +92,6 @@ public class MailApiController {
     @Resource
     private UserMailTrackRecordService mailTrackRecordService;
 
-
     @Resource
     private Environment environment;
 

+ 173 - 5
storlead-api/src/main/java/com/storlead/mail/mkt/MktMailApiController.java

@@ -1,28 +1,56 @@
 package com.storlead.mail.mkt;
 
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.result.Result;
+import com.storlead.framework.common.util.DateUtils;
 import com.storlead.framework.util.LoginUserUtil;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
+import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.enums.MailSceneEnum;
+import com.storlead.sales.mail.common.properties.MailFileProperties;
+import com.storlead.sales.mail.common.service.MailAccountBizService;
+import com.storlead.sales.mail.common.service.MailTempAttachmentService;
+import com.storlead.sales.mail.common.util.SafeFilePathResolver;
+import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
+import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
+import lombok.extern.log4j.Log4j2;
+import org.apache.commons.io.FilenameUtils;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
+import org.springframework.util.CollectionUtils;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletResponse;
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.net.URLEncoder;
+import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Base64;
+import java.util.Date;
+import java.util.List;
 import java.util.Objects;
 
 /**
- * 营销邮件 API(与 CRM /mail 隔离)。
+ * 营销邮件 API(与 CRM /mail 隔离):草稿、附件、发送、追踪
  */
 @RestController
 @RequestMapping("/mail/mkt")
 @Api(tags = "021.营销邮件接口")
+@Log4j2
 public class MktMailApiController {
 
     private static final byte[] PIXEL_GIF = Base64.getDecoder().decode(
@@ -31,8 +59,118 @@ public class MktMailApiController {
     @Resource
     private MktMailMessageService mktMailMessageService;
 
+    @Resource
+    private MktMailAttachmentService mktMailAttachmentService;
+
+    @Resource
+    private MailTempAttachmentService tempAttachmentService;
+
+    @Resource
+    private MailAccountBizService mailAccountBizService;
+
+    @Resource
+    private MailFileProperties mailFileProperties;
+
+    @PostMapping("/file/cache_mail_file")
+    @ApiOperation("缓存营销邮件临时附件(scene=MKT)")
+    public Result<?> cacheMailFile(@RequestParam("files") List<MultipartFile> files,
+                                   @RequestParam("accountId") Long accountId) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        if (CollectionUtils.isEmpty(files) || accountId == null) {
+            return Result.error("参数错误");
+        }
+        try {
+            SmtpPopSettingsEntity account = mailAccountBizService.requireAccount(accountId, MailSceneEnum.MKT);
+            String day = DateUtils.date2Str(new Date(), DateUtils.yyyyMMdd);
+            String downloadDir = mailFileProperties.getPath().getPath() + File.separator
+                    + account.getEmailAddress() + File.separator + day + File.separator;
+            List<JSONObject> res = new ArrayList<>();
+            for (MultipartFile file : files) {
+                Long fileId = tempAttachmentService.saveTempAttachment(
+                        file, downloadDir, null, MailSceneEnum.MKT.getCode());
+                if (fileId == null) {
+                    return Result.error("上传错误");
+                }
+                MailTempAttachmentEntity saved = tempAttachmentService.getById(fileId);
+                if (saved != null) {
+                    saved.setOwnerBy(userId);
+                    tempAttachmentService.updateById(saved);
+                }
+                JSONObject json = new JSONObject();
+                json.put("id", fileId);
+                json.put("fileShowName", file.getOriginalFilename());
+                json.put("fileName", FilenameUtils.getBaseName(file.getOriginalFilename()));
+                json.put("fileSize", file.getSize());
+                json.put("fileExt", FilenameUtils.getExtension(file.getOriginalFilename()));
+                res.add(json);
+            }
+            return Result.result(res);
+        } catch (Exception e) {
+            log.error("mkt cache_mail_file error", e);
+            return Result.error(e.getMessage() == null ? "上传错误" : e.getMessage());
+        }
+    }
+
+    @PostMapping("/file/list")
+    @ApiOperation("按邮件查询正式附件(mkt_mail_attachment)")
+    public Result<?> listAttachments(@RequestParam Long mailId) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        MktMailMessageEntity mail = mktMailMessageService.getById(mailId);
+        if (mail == null || !Objects.equals(mail.getIsDelete(), CommonConstant.DEL_FLAG_0)) {
+            return Result.error("邮件不存在");
+        }
+        if (mail.getOwnerBy() != null && !Objects.equals(mail.getOwnerBy(), userId)) {
+            return Result.error("无权访问");
+        }
+        return Result.result(mktMailAttachmentService.listByMailId(mailId));
+    }
+
+    @PostMapping("/file/download")
+    @ApiOperation("下载营销正式附件")
+    public void downloadFormalFile(@RequestParam Long fileId, HttpServletResponse response) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        MktMailAttachmentEntity resource = mktMailAttachmentService.getOwned(fileId, userId);
+        if (resource == null) {
+            return;
+        }
+        writeFileResponse(resource.getFilePath(), resource.getFileName(), resource.getFileExt(), response);
+    }
+
+    @PostMapping("/file/download_temp")
+    @ApiOperation("下载营销临时附件")
+    public void downloadTempFile(@RequestParam Long fileId, HttpServletResponse response) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (userId == null || fileId == null) {
+            return;
+        }
+        MailTempAttachmentEntity resource = tempAttachmentService.getById(fileId);
+        if (resource == null
+                || !MailSceneEnum.isMkt(resource.getScene())
+                || (resource.getOwnerBy() != null && !Objects.equals(resource.getOwnerBy(), userId))) {
+            return;
+        }
+        writeFileResponse(resource.getFilePath(), resource.getFileName(), resource.getFileExt(), response);
+    }
+
+    @PostMapping("/save_draft")
+    @ApiOperation("保存营销邮件草稿")
+    public Result<?> saveDraft(@RequestBody MktSendMailDTO dto) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        MktMailMessageEntity entity = mktMailMessageService.saveDraft(dto, userId);
+        return Result.result(entity);
+    }
+
     @PostMapping("/send")
-    @ApiOperation("发送营销邮件(单封)")
+    @ApiOperation("发送营销邮件(单封,支持附件/延时/打开追踪)")
     public Result<?> send(@RequestBody MktSendMailDTO dto) {
         Long userId = LoginUserUtil.getCurrentUserId();
         if (Objects.isNull(userId)) {
@@ -50,10 +188,13 @@ public class MktMailApiController {
     }
 
     @PostMapping("/detail")
-    @ApiOperation("营销邮件详情")
+    @ApiOperation("营销邮件详情(含附件)")
     public Result<?> detail(@RequestParam Long id) {
-        MktMailMessageEntity entity = mktMailMessageService.getById(id);
-        return Result.result(entity);
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        return Result.result(mktMailMessageService.getDetail(id, userId));
     }
 
     @GetMapping(value = "/track/{token}", produces = MediaType.IMAGE_GIF_VALUE)
@@ -62,4 +203,31 @@ public class MktMailApiController {
         mktMailMessageService.markOpened(token);
         return ResponseEntity.ok().contentType(MediaType.IMAGE_GIF).body(PIXEL_GIF);
     }
+
+    private void writeFileResponse(String relativePath, String fileName, String fileExt, HttpServletResponse response) {
+        try {
+            Path filePath = SafeFilePathResolver.resolveUnderBase(
+                    mailFileProperties.getPath().getPath(), relativePath);
+            try (InputStream inputStream = new BufferedInputStream(new FileInputStream(filePath.toFile()));
+                 ServletOutputStream outputStream = response.getOutputStream()) {
+                response.reset();
+                response.setContentType("application/octet-stream;charset=utf-8");
+                response.addHeader("Access-Control-Allow-Origin", "*");
+                String filename = fileName + (fileExt == null || fileExt.isEmpty() ? "" : "." + fileExt);
+                filename = filename.replace(" ", "");
+                response.addHeader("content-disposition",
+                        "attachment;filename=" + URLEncoder.encode(filename, "UTF-8")
+                                + ";filename*=utf-8''" + URLEncoder.encode(filename, "UTF-8"));
+                byte[] buf = new byte[1024];
+                int len;
+                while ((len = inputStream.read(buf)) > 0) {
+                    outputStream.write(buf, 0, len);
+                }
+            }
+        } catch (IllegalArgumentException e) {
+            log.warn("mkt download rejected unsafe path");
+        } catch (Exception e) {
+            log.error("mkt download error", e);
+        }
+    }
 }

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

@@ -43,6 +43,8 @@ storlead:
       - sys_app
       - mail_integration_task
   mail:
+    # 营销打开追踪像素公网根地址(含 context-path),例:https://xxx/router/mail
+    track-base-url: ${domainname:}
     integration:
       enabled: true
       poll-interval-ms: 30000

+ 13 - 2
storlead-mail/README.md

@@ -8,7 +8,7 @@
 |------|----------|------|
 | common | `storlead-mail-common` | 账户(`smtp_pop_settings`)、SMTP/IMAP 连接、公共投递、`MailSceneEnum` |
 | crm | `storlead-mail-crm` | CRM 个人邮箱:`emails` / `client_sent_emails`、收信、文件夹、CRM 集成任务 |
-| mkt | `storlead-mail-mkt` | 营销投递:`mkt_mail_message`、发送与打开追踪 |
+| mkt | `storlead-mail-mkt` | 营销投递:`mkt_mail_message` / `mkt_mail_attachment`、草稿、附件、延时、打开追踪 |
 | dispatch | `storlead-mail-dispatch` | **统一调度**:定时发送(CRM+MKT)、CRM 收信拉取、服务器邮件延迟删除 |
 
 依赖方向:
@@ -34,6 +34,16 @@ API:
 - `com.storlead.mail.common` — 账户配置
 - `com.storlead.mail.crm` — CRM 邮件接口(原 `/mail/**`)
 - `com.storlead.mail.mkt` — 营销接口(`/mail/mkt/**`)
+  - `POST /mail/mkt/file/cache_mail_file` — 上传临时附件(`scene=MKT`,需 `accountId`)
+  - `POST /mail/mkt/file/list` — 按邮件查正式附件 `mkt_mail_attachment`
+  - `POST /mail/mkt/file/download` — 下载正式附件
+  - `POST /mail/mkt/file/download_temp` — 下载临时附件
+  - `POST /mail/mkt/save_draft` — 保存草稿(`status=-2`,绑定 `fileIds`/`oldFileIds`)
+  - `POST /mail/mkt/send` — 发送(CID 内嵌图、附件、延时、追踪像素)
+  - `POST /mail/mkt/detail` — 详情(草稿带临时附件,成功带正式附件)
+  - `GET /mail/mkt/track/{token}` — 打开追踪像素
+
+临时附件共用 `mail_temp_attachment`(`scene` 默认 `CRM`);正式附件 CRM/MKT 分表。
 
 ## 调度任务
 
@@ -48,4 +58,5 @@ API:
 执行:`storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql`
 
 - `smtp_pop_settings` 增加 `scene` / `account_scope` / `org_id` 等
-- 新建 `mkt_mail_message`
+- 新建 `mkt_mail_message`、`mkt_mail_attachment`
+- `mail_temp_attachment` 增加 `scene`(默认 `CRM`)

+ 5 - 1
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/entity/MailTempAttachmentEntity.java → storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/entity/MailTempAttachmentEntity.java

@@ -1,4 +1,4 @@
-package com.storlead.sales.mail.crm.entity;
+package com.storlead.sales.mail.common.entity;
 
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.baomidou.mybatisplus.annotation.IdType;
@@ -56,6 +56,10 @@ public class MailTempAttachmentEntity extends SysBaseField {
     @TableField("file_ext")
     private String fileExt;
 
+    @ApiModelProperty(value = "场景:CRM/MKT,默认CRM(存量与未传均按CRM)")
+    @TableField("scene")
+    private String scene;
+
     @ApiModelProperty(value = "文件大小+单位")
     @TableField(exist = false)
     private String fileSizeName;

+ 2 - 2
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/mapper/MailTempAttachmentMapper.java → storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/mapper/MailTempAttachmentMapper.java

@@ -1,6 +1,6 @@
-package com.storlead.sales.mail.crm.mapper;
+package com.storlead.sales.mail.common.mapper;
 
-import com.storlead.sales.mail.crm.entity.MailTempAttachmentEntity;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
 import com.storlead.framework.mybatis.mapper.MyBaseMapper;
 
 /**

+ 7 - 12
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/MailTempAttachmentService.java → storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/MailTempAttachmentService.java

@@ -1,28 +1,23 @@
-package com.storlead.sales.mail.crm.service;
+package com.storlead.sales.mail.common.service;
 
-import com.storlead.sales.mail.crm.entity.MailAttachmentEntity;
-import com.storlead.sales.mail.crm.entity.MailTempAttachmentEntity;
 import com.storlead.framework.mybatis.service.MyBaseService;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.io.File;
 
 /**
- * <p>
- * 临时邮件附件表 服务类
- * </p>
- *
- * @author chenkq
- * @since 2024-08-09
+ * 临时邮件附件表服务。默认 scene=CRM,营销上传时显式传 MKT。
  */
 public interface MailTempAttachmentService extends MyBaseService<MailTempAttachmentEntity> {
 
     MailTempAttachmentEntity saveAttachmentToDatabase(File file, String code, Long mailId);
 
-    Long saveTempAttachment(MultipartFile file, String downloadDir, Long mailId);
+    MailTempAttachmentEntity saveAttachmentToDatabase(File file, String code, Long mailId, String scene);
 
-    MailTempAttachmentEntity saveTempImageAttachment(MultipartFile file, String downloadDir);
+    Long saveTempAttachment(MultipartFile file, String downloadDir, Long mailId);
 
-//    Long saveAttachmentToDatabase(File file, String code, Long mailId);
+    Long saveTempAttachment(MultipartFile file, String downloadDir, Long mailId, String scene);
 
+    MailTempAttachmentEntity saveTempImageAttachment(MultipartFile file, String downloadDir);
 }

+ 18 - 11
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/impl/MailTempAttachmentServiceImpl.java → storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/impl/MailTempAttachmentServiceImpl.java

@@ -1,25 +1,22 @@
-package com.storlead.sales.mail.crm.service.impl;
+package com.storlead.sales.mail.common.service.impl;
 
 import cn.hutool.core.util.StrUtil;
 import com.storlead.framework.common.util.RandomGenerateHelper;
-import com.storlead.sales.mail.crm.entity.MailAttachmentEntity;
-import com.storlead.sales.mail.crm.entity.MailTempAttachmentEntity;
-import com.storlead.sales.mail.crm.mapper.MailTempAttachmentMapper;
+import com.storlead.sales.mail.common.enums.MailSceneEnum;
 import com.storlead.sales.mail.common.properties.MailFileProperties;
-import com.storlead.sales.mail.crm.service.MailTempAttachmentService;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
+import com.storlead.sales.mail.common.mapper.MailTempAttachmentMapper;
+import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
-import com.storlead.sales.mail.crm.util.FileHelper;
 import org.apache.commons.io.FilenameUtils;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
-import javax.mail.BodyPart;
-import javax.mail.MessagingException;
 import javax.mail.internet.MimeUtility;
 import java.io.File;
 import java.io.FileOutputStream;
-import java.io.IOException;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.UUID;
@@ -40,7 +37,11 @@ public class MailTempAttachmentServiceImpl extends MyBaseServiceImpl<MailTempAtt
 
     @Override
     public MailTempAttachmentEntity saveAttachmentToDatabase(File file, String code,Long mailId) {
+        return saveAttachmentToDatabase(file, code, mailId, MailSceneEnum.CRM.getCode());
+    }
 
+    @Override
+    public MailTempAttachmentEntity saveAttachmentToDatabase(File file, String code, Long mailId, String scene) {
         String fileName = StrUtil.isBlank(code) ? file.getName() : file.getName().replace("."+code,"");
         MailTempAttachmentEntity attachment = new MailTempAttachmentEntity();
         attachment.setEmailId(mailId);
@@ -49,18 +50,24 @@ public class MailTempAttachmentServiceImpl extends MyBaseServiceImpl<MailTempAtt
         attachment.setFilePath(file.getAbsolutePath().replace(mailFileProperties.getPath().getPath(),""));
         attachment.setFileExt(FilenameUtils.getExtension(fileName));
         attachment.setFileSize(file.length());
+        // 未传或空值一律按 CRM,保证存量默认逻辑不变
+        attachment.setScene(StringUtils.hasText(scene) ? scene : MailSceneEnum.CRM.getCode());
         this.save(attachment);
         return attachment;
     }
 
     @Override
     public Long saveTempAttachment(MultipartFile file, String downloadDir, Long mailId) {
+        return saveTempAttachment(file, downloadDir, mailId, MailSceneEnum.CRM.getCode());
+    }
+
+    @Override
+    public Long saveTempAttachment(MultipartFile file, String downloadDir, Long mailId, String scene) {
         try {
             String fileName = file.getOriginalFilename();
             fileName = MimeUtility.decodeText(fileName);
             fileName = fileName.replace("\n", "").replace("\r", "");
             String code =  RandomGenerateHelper.generateRandomString(6);
-            // 如果不存在这创建路径
             if (!new File(downloadDir).exists()) {
                 new File(downloadDir).mkdirs();
             }
@@ -69,7 +76,7 @@ public class MailTempAttachmentServiceImpl extends MyBaseServiceImpl<MailTempAtt
             try (FileOutputStream output = new FileOutputStream(filePath)) {
                 file.getInputStream().transferTo(output);
             }
-            MailTempAttachmentEntity attachment = saveAttachmentToDatabase(filePath,code, mailId);
+            MailTempAttachmentEntity attachment = saveAttachmentToDatabase(filePath, code, mailId, scene);
             return attachment.getId();
         }catch (Exception e) {
             log.error("--upload -- saveTempAttachment --error --",e);

+ 4 - 3
storlead-mail/storlead-mail-crm/src/main/resources/mapper/MailTempAttachmentMapper.xml → storlead-mail/storlead-mail-common/src/main/resources/mapper/MailTempAttachmentMapper.xml

@@ -1,9 +1,9 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-<mapper namespace="com.storlead.sales.mail.crm.mapper.MailTempAttachmentMapper">
+<mapper namespace="com.storlead.sales.mail.common.mapper.MailTempAttachmentMapper">
 
         <!-- 通用查询映射结果 -->
-        <resultMap id="BaseResultMap" type="com.storlead.sales.mail.crm.entity.MailTempAttachmentEntity">
+        <resultMap id="BaseResultMap" type="com.storlead.sales.mail.common.entity.MailTempAttachmentEntity">
                     <id column="id" property="id" />
                 <result column="create_by" property="createBy" />
                 <result column="create_time" property="createTime" />
@@ -19,6 +19,7 @@
                     <result column="file_path" property="filePath" />
                     <result column="file_size" property="fileSize" />
                     <result column="file_ext" property="fileExt" />
+                    <result column="scene" property="scene" />
         </resultMap>
 
         <!-- 通用查询结果列 -->
@@ -31,7 +32,7 @@
                 sort,
                 update_time,
                 is_delete,
-            id, email_id, file_code, file_name, file_path, file_size, file_ext
+            id, email_id, file_code, file_name, file_path, file_size, file_ext, scene
         </sql>
 
 </mapper>

+ 30 - 1
storlead-mail/storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql

@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS mkt_mail_message (
     recipient_cc    text         NULL COMMENT '抄送人,多个用逗号分隔',
     recipient_bcc   text         NULL COMMENT '密送人,多个用逗号分隔',
     content         mediumtext   NULL COMMENT '邮件正文(HTML/文本);体积大时可后续改为外置只存引用',
-    status          tinyint      NOT NULL DEFAULT 0 COMMENT '投递状态:-1待定时发送;0发送中;1发送成功;2发送失败',
+    status          tinyint      NOT NULL DEFAULT 0 COMMENT '投递状态:-2草稿;-1待定时发送;0发送中;1发送成功;2发送失败',
     fail_reason     varchar(512) NULL COMMENT '发送失败原因(异常信息摘要)',
     is_track        tinyint      NOT NULL DEFAULT 0 COMMENT '是否开启打开追踪:0否;1是',
     opened          tinyint      NOT NULL DEFAULT 0 COMMENT '是否已打开(像素回调):0未打开;1已打开;存在网关预取假阳性,仅为弱信号',
@@ -47,3 +47,32 @@ CREATE TABLE IF NOT EXISTS mkt_mail_message (
     KEY idx_mkt_account_sent (account_id, sent_time),
     KEY idx_mkt_org_sent (org_id, sent_time)
 ) COMMENT='营销邮件投递记录';
+
+-- 临时附件增加场景字段:默认 CRM,存量与未传 scene 的上传仍走 CRM 逻辑
+ALTER TABLE mail_temp_attachment
+    ADD COLUMN scene varchar(16) NOT NULL DEFAULT 'CRM' COMMENT '场景:CRM/MKT,默认CRM' AFTER file_ext;
+
+CREATE INDEX idx_mail_temp_att_scene ON mail_temp_attachment (scene, email_id);
+
+-- 营销正式附件表(与 CRM mail_attachment 分表;mail_id 指向 mkt_mail_message.id)
+CREATE TABLE IF NOT EXISTS mkt_mail_attachment (
+    id           bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    mail_id      bigint       NOT NULL COMMENT '营销邮件ID,对应 mkt_mail_message.id',
+    account_id   bigint       NULL COMMENT '发信账户ID,对应 smtp_pop_settings.id',
+    file_code    varchar(64)  NULL COMMENT '文件编码/随机码',
+    file_name    varchar(255) NULL COMMENT '文件名(不含扩展名)',
+    file_path    varchar(512) NULL COMMENT '相对存储路径',
+    file_size    bigint       NULL COMMENT '文件大小(字节)',
+    file_ext     varchar(32)  NULL COMMENT '扩展名',
+    owner_by     bigint       NULL COMMENT '数据归属人',
+    create_by    bigint       NULL COMMENT '创建人',
+    update_by    bigint       NULL COMMENT '最后更新人',
+    create_time  datetime     NULL COMMENT '创建时间',
+    update_time  datetime     NULL COMMENT '最后更新时间',
+    is_delete    tinyint      NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除;1已删除',
+    enabled      tinyint      NOT NULL DEFAULT 1 COMMENT '是否启用:1启用;0禁用',
+    sort         int          NULL COMMENT '排序号',
+    PRIMARY KEY (id),
+    KEY idx_mkt_att_mail (mail_id),
+    KEY idx_mkt_att_account (account_id)
+) COMMENT='营销邮件正式附件';

+ 2 - 0
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/impl/EmailsServiceImpl.java

@@ -1,7 +1,9 @@
 package com.storlead.sales.mail.crm.service.impl;
 
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
 import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
+import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 
 import cn.hutool.core.util.StrUtil;
 import com.alibaba.fastjson.JSON;

+ 2 - 2
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/support/MailOwnershipGuard.java

@@ -7,12 +7,12 @@ import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.mybatis.entity.SysBaseField;
 import com.storlead.sales.mail.crm.entity.EmailsEntity;
 import com.storlead.sales.mail.crm.entity.MailAttachmentEntity;
-import com.storlead.sales.mail.crm.entity.MailTempAttachmentEntity;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
 import com.storlead.sales.mail.crm.entity.UserEmailFolderEntity;
 import com.storlead.sales.mail.crm.service.EmailsService;
 import com.storlead.sales.mail.crm.service.MailAttachmentService;
-import com.storlead.sales.mail.crm.service.MailTempAttachmentService;
+import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
 import com.storlead.sales.mail.crm.service.UserEmailFolderService;
 import org.springframework.stereotype.Component;

+ 55 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailAttachmentEntity.java

@@ -0,0 +1,55 @@
+package com.storlead.sales.mail.mkt.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.storlead.framework.mybatis.entity.SysBaseField;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+/**
+ * 营销邮件正式附件(与 CRM mail_attachment 分表)。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Accessors(chain = true)
+@TableName("mkt_mail_attachment")
+@ApiModel(value = "MktMailAttachmentEntity", description = "营销邮件附件表")
+public class MktMailAttachmentEntity extends SysBaseField {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("营销邮件ID,对应 mkt_mail_message.id")
+    @TableField("mail_id")
+    private Long mailId;
+
+    @ApiModelProperty("发信账户ID")
+    @TableField("account_id")
+    private Long accountId;
+
+    @TableField("file_code")
+    private String fileCode;
+
+    @TableField("file_name")
+    private String fileName;
+
+    @TableField("file_path")
+    private String filePath;
+
+    @TableField("file_size")
+    private Long fileSize;
+
+    @TableField("file_ext")
+    private String fileExt;
+
+    @ApiModelProperty("文件大小展示名")
+    @TableField(exist = false)
+    private String fileSizeName;
+}

+ 9 - 1
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailMessageEntity.java

@@ -12,6 +12,7 @@ import lombok.EqualsAndHashCode;
 import lombok.experimental.Accessors;
 
 import java.util.Date;
+import java.util.List;
 
 /**
  * 营销邮件投递记录(与 CRM emails 分表)。
@@ -71,7 +72,7 @@ public class MktMailMessageEntity extends SysBaseField {
     @TableField("content")
     private String content;
 
-    @ApiModelProperty("发送状态:-1待定时 0发送中 1成功 2失败")
+    @ApiModelProperty("发送状态:-2草稿 -1待定时 0发送中 1成功 2失败")
     @TableField("status")
     private Integer status;
 
@@ -95,4 +96,11 @@ public class MktMailMessageEntity extends SysBaseField {
 
     @TableField("delay_send_time")
     private Date delaySendTime;
+
+    /**
+     * 详情附件:草稿/延时/失败为临时附件;发送成功为 mkt_mail_attachment。
+     */
+    @ApiModelProperty("附件列表(非表字段)")
+    @TableField(exist = false)
+    private List<?> attachments;
 }

+ 7 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/mapper/MktMailAttachmentMapper.java

@@ -0,0 +1,7 @@
+package com.storlead.sales.mail.mkt.mapper;
+
+import com.storlead.framework.mybatis.mapper.MyBaseMapper;
+import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
+
+public interface MktMailAttachmentMapper extends MyBaseMapper<MktMailAttachmentEntity> {
+}

+ 18 - 2
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktSendMailDTO.java

@@ -1,20 +1,25 @@
 package com.storlead.sales.mail.mkt.pojo;
 
+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.util.Date;
 import java.util.List;
 
 @Data
-@ApiModel("营销邮件发送请求")
+@ApiModel("营销邮件发送/草稿请求")
 public class MktSendMailDTO {
 
+    @ApiModelProperty("主键,草稿更新或草稿转发送时传入")
+    private Long id;
+
     @ApiModelProperty(value = "发信账户ID", required = true)
     private Long accountId;
 
-    @ApiModelProperty(value = "调用方业务单号", required = true)
+    @ApiModelProperty("调用方业务单号")
     private String bizRef;
 
     @ApiModelProperty("批量批次号")
@@ -35,9 +40,20 @@ public class MktSendMailDTO {
     @ApiModelProperty(value = "正文HTML", required = true)
     private String content;
 
+    @ApiModelProperty("备注")
+    private String remark;
+
+    @ApiModelProperty("临时附件ID列表(mail_temp_attachment,scene=MKT)")
+    private List<Long> fileIds;
+
+    @ApiModelProperty("已落库正式附件ID列表(mkt_mail_attachment)")
+    private List<Long> oldFileIds;
+
     @ApiModelProperty("是否开启打开追踪:0否 1是")
     private Integer isTrack;
 
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
     @ApiModelProperty("定时发送时间")
     private Date delaySendTime;
 }

+ 22 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailAttachmentService.java

@@ -0,0 +1,22 @@
+package com.storlead.sales.mail.mkt.service;
+
+import com.storlead.framework.mybatis.service.MyBaseService;
+import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
+
+import java.util.List;
+
+/**
+ * 营销正式附件(mkt_mail_attachment)。
+ */
+public interface MktMailAttachmentService extends MyBaseService<MktMailAttachmentEntity> {
+
+    /**
+     * 按营销邮件 ID 查询正式附件列表。
+     */
+    List<MktMailAttachmentEntity> listByMailId(Long mailId);
+
+    /**
+     * 校验附件归属(owner_by);通过返回实体,否则 null。
+     */
+    MktMailAttachmentEntity getOwned(Long attachmentId, Long operatorId);
+}

+ 11 - 5
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailMessageService.java

@@ -7,25 +7,31 @@ import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
 
 /**
- * 营销邮件投递业务。
+ * 营销邮件投递业务(对齐 CRM:草稿、附件、延时、发送)
  */
 public interface MktMailMessageService extends MyBaseService<MktMailMessageEntity> {
 
     /**
-     * 使用 MKT 场景账户发送一封营销邮件,写入 mkt_mail_message。
-     * 若设置了未来定时时间,则仅落库为待发送(status=-1),由调度任务触发。
+     * 保存/更新草稿(status=-2),并绑定临时附件。
+     */
+    MktMailMessageEntity saveDraft(MktSendMailDTO dto, Long operatorId);
+
+    /**
+     * 发送营销邮件;支持附件、CID 内嵌图、延时发送、打开追踪像素。
      */
     MktMailMessageEntity send(MktSendMailDTO dto, Long operatorId);
 
     /**
-     * 扫描并发送到期的营销定时邮件,返回处理条数。
+     * 扫描并发送到期的营销定时邮件,返回成功条数。
      */
     int sendDueDelayedMails();
 
     IPage<MktMailMessageEntity> pageList(MktMailQueryDTO query);
 
     /**
-     * 打开追踪回调
+     * 详情:草稿/延时/失败带临时附件;发送成功带 mkt_mail_attachment
      */
+    MktMailMessageEntity getDetail(Long id, Long operatorId);
+
     boolean markOpened(String trackToken);
 }

+ 65 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailAttachmentServiceImpl.java

@@ -0,0 +1,65 @@
+package com.storlead.sales.mail.mkt.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
+import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
+import com.storlead.sales.mail.mkt.mapper.MktMailAttachmentMapper;
+import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+@Service
+public class MktMailAttachmentServiceImpl
+        extends MyBaseServiceImpl<MktMailAttachmentMapper, MktMailAttachmentEntity>
+        implements MktMailAttachmentService {
+
+    @Override
+    public List<MktMailAttachmentEntity> listByMailId(Long mailId) {
+        if (mailId == null) {
+            return Collections.emptyList();
+        }
+        List<MktMailAttachmentEntity> list = this.list(new LambdaQueryWrapper<MktMailAttachmentEntity>()
+                .eq(MktMailAttachmentEntity::getMailId, mailId)
+                .eq(MktMailAttachmentEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .orderByAsc(MktMailAttachmentEntity::getId));
+        if (!CollectionUtils.isEmpty(list)) {
+            list.forEach(e -> e.setFileSizeName(formatFileSize(e.getFileSize())));
+        }
+        return list;
+    }
+
+    @Override
+    public MktMailAttachmentEntity getOwned(Long attachmentId, Long operatorId) {
+        if (attachmentId == null || operatorId == null) {
+            return null;
+        }
+        MktMailAttachmentEntity entity = this.getById(attachmentId);
+        if (entity == null || !Objects.equals(entity.getIsDelete(), CommonConstant.DEL_FLAG_0)) {
+            return null;
+        }
+        if (entity.getOwnerBy() != null && !Objects.equals(entity.getOwnerBy(), operatorId)) {
+            return null;
+        }
+        entity.setFileSizeName(formatFileSize(entity.getFileSize()));
+        return entity;
+    }
+
+    static String formatFileSize(Long size) {
+        if (size == null || size <= 0) {
+            return "0B";
+        }
+        final String[] units = {"B", "KB", "MB", "GB"};
+        double value = size.doubleValue();
+        int idx = 0;
+        while (value >= 1024 && idx < units.length - 1) {
+            value /= 1024;
+            idx++;
+        }
+        return (idx == 0 ? String.valueOf(size.longValue()) : String.format("%.2f", value)) + units[idx];
+    }
+}

+ 325 - 49
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java

@@ -8,37 +8,104 @@ import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.util.encryptor.AccessKeyEncryptor;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.framework.web.exception.WebException;
+import com.storlead.sales.mail.common.entity.MailTempAttachmentEntity;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
 import com.storlead.sales.mail.common.enums.MailSceneEnum;
+import com.storlead.sales.mail.common.properties.MailFileProperties;
 import com.storlead.sales.mail.common.service.MailAccountBizService;
+import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 import com.storlead.sales.mail.common.transport.MailTransportSupport;
+import com.storlead.sales.mail.common.util.EmailHelper;
+import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
 import com.storlead.sales.mail.mkt.mapper.MktMailMessageMapper;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
+import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
+import javax.activation.DataHandler;
+import javax.activation.DataSource;
+import javax.activation.FileDataSource;
 import javax.annotation.Resource;
+import javax.mail.BodyPart;
 import javax.mail.Message;
+import javax.mail.Multipart;
 import javax.mail.Session;
 import javax.mail.internet.InternetAddress;
+import javax.mail.internet.MimeBodyPart;
 import javax.mail.internet.MimeMessage;
+import javax.mail.internet.MimeMultipart;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
+import java.util.Objects;
 import java.util.UUID;
 import java.util.stream.Collectors;
 
+/**
+ * 营销邮件:对齐 CRM 草稿 / 附件 / 延时 / 发送,成功后写入 mkt_mail_attachment。
+ */
+@Log4j2
 @Service
 public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageMapper, MktMailMessageEntity>
         implements MktMailMessageService {
 
+    /** 草稿 */
+    public static final int STATUS_DRAFT = -2;
+    /** 待定时发送 */
+    public static final int STATUS_DELAY = -1;
+    /** 发送中 */
+    public static final int STATUS_SENDING = 0;
+    /** 成功 */
+    public static final int STATUS_SUCCESS = 1;
+    /** 失败 */
+    public static final int STATUS_FAIL = 2;
+
     @Resource
     private MailAccountBizService mailAccountBizService;
 
+    @Resource
+    private MailTempAttachmentService tempAttachmentService;
+
+    @Resource
+    private MktMailAttachmentService mktMailAttachmentService;
+
+    @Resource
+    private MailFileProperties mailFileProperties;
+
+    /**
+     * 打开追踪像素根地址,需可被收件方公网访问,例如 https://host/router/mail
+     */
+    @Value("${storlead.mail.track-base-url:${domainname:}}")
+    private String trackBaseUrl;
+
     @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MktMailMessageEntity saveDraft(MktSendMailDTO dto, Long operatorId) {
+        if (dto == null || dto.getAccountId() == null) {
+            throw new WebException("发信账户不能为空");
+        }
+        SmtpPopSettingsEntity account = mailAccountBizService.requireAccount(dto.getAccountId(), MailSceneEnum.MKT);
+        MktMailMessageEntity entity = resolveWritableEntity(dto.getId(), operatorId);
+        fillMessageFields(entity, dto, account, operatorId);
+        entity.setStatus(STATUS_DRAFT);
+        entity.setDelaySendTime(dto.getDelaySendTime());
+        ensureTrackToken(entity, dto.getIsTrack());
+        this.saveOrUpdate(entity);
+        bindTempAttachments(entity.getId(), dto.getFileIds(), operatorId);
+        copyFormalToTemp(entity.getId(), dto.getOldFileIds(), operatorId);
+        return entity;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
     public MktMailMessageEntity send(MktSendMailDTO dto, Long operatorId) {
         if (dto == null || dto.getAccountId() == null) {
             throw new WebException("发信账户不能为空");
@@ -51,39 +118,27 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         }
         SmtpPopSettingsEntity account = mailAccountBizService.requireAccount(dto.getAccountId(), MailSceneEnum.MKT);
 
-        MktMailMessageEntity entity = new MktMailMessageEntity();
-        entity.setAccountId(account.getId());
-        entity.setOrgId(account.getOrgId());
-        entity.setBizRef(dto.getBizRef());
-        entity.setBizBatchNo(dto.getBizBatchNo());
-        entity.setSubject(dto.getSubject());
-        entity.setFromAddr(account.getEmailAddress());
-        entity.setFromName(account.getSenderNickName());
-        entity.setRecipient(dto.getRecipient().trim());
-        entity.setRecipientCc(joinEmails(dto.getRecipientCcList()));
-        entity.setRecipientBcc(joinEmails(dto.getRecipientBccList()));
-        entity.setContent(dto.getContent());
-        entity.setIsTrack(dto.getIsTrack() == null ? 0 : dto.getIsTrack());
-        entity.setOpened(0);
-        entity.setOpenCount(0);
-        entity.setDelaySendTime(dto.getDelaySendTime());
-        entity.setOwnerBy(operatorId);
-        entity.setCreateBy(operatorId);
-        entity.setIsDelete(CommonConstant.DEL_FLAG_0);
-        entity.setEnabled(Boolean.TRUE);
-        if (Integer.valueOf(1).equals(entity.getIsTrack())) {
-            entity.setTrackToken(UUID.randomUUID().toString().replace("-", ""));
-        }
+        MktMailMessageEntity entity = resolveWritableEntity(dto.getId(), operatorId);
+        fillMessageFields(entity, dto, account, operatorId);
+        ensureTrackToken(entity, dto.getIsTrack());
 
         boolean delayed = dto.getDelaySendTime() != null && dto.getDelaySendTime().after(new Date());
         if (delayed) {
-            entity.setStatus(-1);
-            this.save(entity);
+            entity.setStatus(STATUS_DELAY);
+            entity.setDelaySendTime(dto.getDelaySendTime());
+            this.saveOrUpdate(entity);
+            bindTempAttachments(entity.getId(), dto.getFileIds(), operatorId);
+            copyFormalToTemp(entity.getId(), dto.getOldFileIds(), operatorId);
             return entity;
         }
 
-        entity.setStatus(0);
-        this.save(entity);
+        entity.setStatus(STATUS_SENDING);
+        entity.setDelaySendTime(null);
+        this.saveOrUpdate(entity);
+        bindTempAttachments(entity.getId(), dto.getFileIds(), operatorId);
+        // 正式附件拷入临时表,MIME 与成功后转正式统一按 mailId 处理,避免重复挂载
+        copyFormalToTemp(entity.getId(), dto.getOldFileIds(), operatorId);
+
         doSmtpSend(entity, decryptAccount(account));
         return entity;
     }
@@ -94,7 +149,7 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         List<MktMailMessageEntity> dueList = this.list(new LambdaQueryWrapper<MktMailMessageEntity>()
                 .select(MktMailMessageEntity::getId, MktMailMessageEntity::getAccountId)
                 .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
-                .eq(MktMailMessageEntity::getStatus, -1)
+                .eq(MktMailMessageEntity::getStatus, STATUS_DELAY)
                 .isNotNull(MktMailMessageEntity::getDelaySendTime)
                 .le(MktMailMessageEntity::getDelaySendTime, now));
         if (CollectionUtils.isEmpty(dueList)) {
@@ -102,15 +157,15 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         }
         List<Long> ids = dueList.stream().map(MktMailMessageEntity::getId).collect(Collectors.toList());
         LambdaUpdateWrapper<MktMailMessageEntity> claiming = new LambdaUpdateWrapper<>();
-        claiming.set(MktMailMessageEntity::getStatus, 0);
+        claiming.set(MktMailMessageEntity::getStatus, STATUS_SENDING);
         claiming.in(MktMailMessageEntity::getId, ids);
-        claiming.eq(MktMailMessageEntity::getStatus, -1);
+        claiming.eq(MktMailMessageEntity::getStatus, STATUS_DELAY);
         this.update(claiming);
 
         int success = 0;
         for (MktMailMessageEntity item : dueList) {
             MktMailMessageEntity entity = this.getById(item.getId());
-            if (entity == null || !Integer.valueOf(0).equals(entity.getStatus())) {
+            if (entity == null || !Integer.valueOf(STATUS_SENDING).equals(entity.getStatus())) {
                 continue;
             }
             try {
@@ -118,7 +173,8 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
                 doSmtpSend(entity, decryptAccount(account));
                 success++;
             } catch (Exception ex) {
-                entity.setStatus(2);
+                log.error("mkt delay send fail, id={}", item.getId(), ex);
+                entity.setStatus(STATUS_FAIL);
                 entity.setFailReason(ex.getMessage());
                 this.updateById(entity);
             }
@@ -147,6 +203,32 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         return this.page(page, wrapper);
     }
 
+    @Override
+    public MktMailMessageEntity getDetail(Long id, Long operatorId) {
+        if (id == null) {
+            throw new WebException("参数错误");
+        }
+        MktMailMessageEntity entity = this.getById(id);
+        if (entity == null || !Objects.equals(entity.getIsDelete(), CommonConstant.DEL_FLAG_0)) {
+            throw new WebException("邮件不存在");
+        }
+        if (operatorId != null && entity.getOwnerBy() != null && !Objects.equals(entity.getOwnerBy(), operatorId)) {
+            throw new WebException("无权访问该邮件");
+        }
+        // 发送成功:正式附件;其余状态(草稿/延时/发送中/失败):临时附件
+        if (Integer.valueOf(STATUS_SUCCESS).equals(entity.getStatus())) {
+            entity.setAttachments(mktMailAttachmentService.listByMailId(id));
+        } else {
+            List<MailTempAttachmentEntity> temps = resolveTempAttachments(id);
+            if (!CollectionUtils.isEmpty(temps)) {
+                temps.forEach(e -> e.setFileSizeName(
+                        MktMailAttachmentServiceImpl.formatFileSize(e.getFileSize())));
+            }
+            entity.setAttachments(temps);
+        }
+        return entity;
+    }
+
     @Override
     public boolean markOpened(String trackToken) {
         if (!StringUtils.hasText(trackToken)) {
@@ -171,36 +253,230 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     private void doSmtpSend(MktMailMessageEntity entity, SmtpPopSettingsEntity account) {
         try {
             Session session = MailTransportSupport.createSmtpSession(account);
-            MimeMessage message = new MimeMessage(session);
-            String fromName = StringUtils.hasText(account.getSenderNickName())
-                    ? account.getSenderNickName() : account.getEmailAddress();
-            message.setFrom(new InternetAddress(account.getEmailAddress(), fromName, "UTF-8"));
-            message.setRecipient(Message.RecipientType.TO, new InternetAddress(entity.getRecipient()));
-            if (StringUtils.hasText(entity.getRecipientCc())) {
-                message.setRecipients(Message.RecipientType.CC, InternetAddress.parse(entity.getRecipientCc()));
-            }
-            if (StringUtils.hasText(entity.getRecipientBcc())) {
-                message.setRecipients(Message.RecipientType.BCC, InternetAddress.parse(entity.getRecipientBcc()));
-            }
-            message.setSubject(entity.getSubject(), "UTF-8");
-            String html = entity.getContent() == null ? "" : entity.getContent();
-            message.setContent(html, "text/html;charset=UTF-8");
-            message.saveChanges();
+            MimeMessage message = buildMimeMessage(session, entity, account);
             MailTransportSupport.send(message);
 
             entity.setMessageId(message.getMessageID());
-            entity.setStatus(1);
+            entity.setStatus(STATUS_SUCCESS);
             entity.setSentTime(new Date());
             entity.setFailReason(null);
             this.updateById(entity);
+            promoteTempAttachments(entity);
         } catch (Exception ex) {
-            entity.setStatus(2);
+            entity.setStatus(STATUS_FAIL);
             entity.setFailReason(ex.getMessage());
             this.updateById(entity);
             throw new WebException("营销邮件发送失败: " + ex.getMessage(), ex);
         }
     }
 
+    private MimeMessage buildMimeMessage(Session session,
+                                         MktMailMessageEntity entity,
+                                         SmtpPopSettingsEntity account) throws Exception {
+        MimeMessage message = new MimeMessage(session);
+        String fromName = StringUtils.hasText(account.getSenderNickName())
+                ? account.getSenderNickName() : account.getEmailAddress();
+        message.setFrom(new InternetAddress(account.getEmailAddress(), fromName, "UTF-8"));
+        message.setRecipient(Message.RecipientType.TO, new InternetAddress(entity.getRecipient()));
+        if (StringUtils.hasText(entity.getRecipientCc())) {
+            message.setRecipients(Message.RecipientType.CC, InternetAddress.parse(entity.getRecipientCc()));
+        }
+        if (StringUtils.hasText(entity.getRecipientBcc())) {
+            message.setRecipients(Message.RecipientType.BCC, InternetAddress.parse(entity.getRecipientBcc()));
+        }
+        message.setSubject(entity.getSubject(), "UTF-8");
+
+        Multipart multipart = new MimeMultipart();
+        BodyPart htmlPart = new MimeBodyPart();
+        List<MimeBodyPart> imageParts = new ArrayList<>();
+        String html = entity.getContent() == null ? "" : entity.getContent();
+        html = injectTrackPixel(html, entity);
+        String updatedHtml = EmailHelper.convertImageUrlsToCidBodyPart(
+                html, imageParts, mailFileProperties.getPath().getJpeg(), multipart);
+        if (updatedHtml == null) {
+            updatedHtml = html;
+        }
+        htmlPart.setContent(updatedHtml, "text/html; charset=UTF-8");
+        multipart.addBodyPart(htmlPart);
+
+        List<MailTempAttachmentEntity> temps = resolveTempAttachments(entity.getId());
+        for (MailTempAttachmentEntity attachment : temps) {
+            multipart.addBodyPart(toAttachmentBodyPart(attachment.getFilePath(),
+                    attachment.getFileName(), attachment.getFileExt()));
+        }
+
+        message.setContent(multipart);
+        message.saveChanges();
+        return message;
+    }
+
+    private BodyPart toAttachmentBodyPart(String relativePath, String fileName, String fileExt) throws Exception {
+        BodyPart messageBodyPart = new MimeBodyPart();
+        String filePath = mailFileProperties.getPath().getPath() + relativePath;
+        String fullName = fileName + (StringUtils.hasText(fileExt) ? "." + fileExt : "");
+        DataSource fileSource = new FileDataSource(filePath);
+        messageBodyPart.setDataHandler(new DataHandler(fileSource));
+        messageBodyPart.setFileName(fullName);
+        return messageBodyPart;
+    }
+
+    private List<MailTempAttachmentEntity> resolveTempAttachments(Long mailId) {
+        if (mailId == null) {
+            return new ArrayList<>();
+        }
+        return tempAttachmentService.list(new LambdaQueryWrapper<MailTempAttachmentEntity>()
+                .eq(MailTempAttachmentEntity::getEmailId, mailId)
+                .eq(MailTempAttachmentEntity::getScene, MailSceneEnum.MKT.getCode()));
+    }
+
+    private String injectTrackPixel(String html, MktMailMessageEntity entity) {
+        if (!Integer.valueOf(1).equals(entity.getIsTrack()) || !StringUtils.hasText(entity.getTrackToken())) {
+            return html;
+        }
+        if (!StringUtils.hasText(trackBaseUrl)) {
+            log.warn("storlead.mail.track-base-url 未配置,跳过打开追踪像素注入, mailId={}", entity.getId());
+            return html;
+        }
+        String base = trackBaseUrl.endsWith("/") ? trackBaseUrl.substring(0, trackBaseUrl.length() - 1) : trackBaseUrl;
+        String pixel = "<img src=\"" + base + "/mail/mkt/track/" + entity.getTrackToken()
+                + "\" width=\"1\" height=\"1\" style=\"display:none\" alt=\"\"/>";
+        return html + pixel;
+    }
+
+    private void promoteTempAttachments(MktMailMessageEntity entity) {
+        List<MailTempAttachmentEntity> temps = tempAttachmentService.list(
+                new LambdaQueryWrapper<MailTempAttachmentEntity>()
+                        .eq(MailTempAttachmentEntity::getEmailId, entity.getId())
+                        .eq(MailTempAttachmentEntity::getScene, MailSceneEnum.MKT.getCode()));
+        if (CollectionUtils.isEmpty(temps)) {
+            return;
+        }
+        List<MktMailAttachmentEntity> formals = new ArrayList<>();
+        for (MailTempAttachmentEntity temp : temps) {
+            MktMailAttachmentEntity formal = new MktMailAttachmentEntity();
+            formal.setMailId(entity.getId());
+            formal.setAccountId(entity.getAccountId());
+            formal.setFileCode(temp.getFileCode());
+            formal.setFileName(temp.getFileName());
+            formal.setFilePath(temp.getFilePath());
+            formal.setFileSize(temp.getFileSize());
+            formal.setFileExt(temp.getFileExt());
+            formal.setOwnerBy(entity.getOwnerBy());
+            formal.setCreateBy(entity.getCreateBy());
+            formal.setIsDelete(CommonConstant.DEL_FLAG_0);
+            formal.setEnabled(Boolean.TRUE);
+            formals.add(formal);
+        }
+        mktMailAttachmentService.saveBatch(formals);
+        tempAttachmentService.removeByIds(temps.stream().map(MailTempAttachmentEntity::getId).collect(Collectors.toList()));
+    }
+
+    private void bindTempAttachments(Long mailId, List<Long> fileIds, Long operatorId) {
+        if (CollectionUtils.isEmpty(fileIds) || mailId == null) {
+            return;
+        }
+        LambdaUpdateWrapper<MailTempAttachmentEntity> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.set(MailTempAttachmentEntity::getEmailId, mailId);
+        wrapper.set(MailTempAttachmentEntity::getScene, MailSceneEnum.MKT.getCode());
+        if (operatorId != null) {
+            wrapper.set(MailTempAttachmentEntity::getOwnerBy, operatorId);
+        }
+        wrapper.in(MailTempAttachmentEntity::getId, fileIds);
+        tempAttachmentService.update(wrapper);
+    }
+
+    private void copyFormalToTemp(Long mailId, List<Long> oldFileIds, Long operatorId) {
+        if (CollectionUtils.isEmpty(oldFileIds) || mailId == null) {
+            return;
+        }
+        List<MktMailAttachmentEntity> formals = mktMailAttachmentService.list(
+                new LambdaQueryWrapper<MktMailAttachmentEntity>().in(MktMailAttachmentEntity::getId, oldFileIds));
+        if (CollectionUtils.isEmpty(formals)) {
+            return;
+        }
+        List<MailTempAttachmentEntity> temps = new ArrayList<>();
+        for (MktMailAttachmentEntity formal : formals) {
+            MailTempAttachmentEntity temp = new MailTempAttachmentEntity();
+            temp.setEmailId(mailId);
+            temp.setFileCode(formal.getFileCode());
+            temp.setFileName(formal.getFileName());
+            temp.setFilePath(formal.getFilePath());
+            temp.setFileSize(formal.getFileSize());
+            temp.setFileExt(formal.getFileExt());
+            temp.setScene(MailSceneEnum.MKT.getCode());
+            temp.setOwnerBy(operatorId);
+            temp.setCreateBy(operatorId);
+            temp.setIsDelete(CommonConstant.DEL_FLAG_0);
+            temp.setEnabled(Boolean.TRUE);
+            temps.add(temp);
+        }
+        tempAttachmentService.saveBatch(temps);
+    }
+
+    private MktMailMessageEntity resolveWritableEntity(Long id, Long operatorId) {
+        if (id == null) {
+            return new MktMailMessageEntity();
+        }
+        MktMailMessageEntity existing = this.getById(id);
+        if (existing == null || !Objects.equals(existing.getIsDelete(), CommonConstant.DEL_FLAG_0)) {
+            throw new WebException("邮件不存在");
+        }
+        if (operatorId != null && existing.getOwnerBy() != null && !Objects.equals(existing.getOwnerBy(), operatorId)) {
+            throw new WebException("无权操作该邮件");
+        }
+        Integer status = existing.getStatus();
+        if (status != null && status != STATUS_DRAFT && status != STATUS_DELAY && status != STATUS_FAIL) {
+            throw new WebException("当前状态不可编辑");
+        }
+        return existing;
+    }
+
+    private void fillMessageFields(MktMailMessageEntity entity,
+                                   MktSendMailDTO dto,
+                                   SmtpPopSettingsEntity account,
+                                   Long operatorId) {
+        entity.setAccountId(account.getId());
+        entity.setOrgId(account.getOrgId());
+        entity.setBizRef(dto.getBizRef());
+        entity.setBizBatchNo(dto.getBizBatchNo());
+        entity.setSubject(dto.getSubject());
+        entity.setFromAddr(account.getEmailAddress());
+        entity.setFromName(account.getSenderNickName());
+        if (StringUtils.hasText(dto.getRecipient())) {
+            entity.setRecipient(dto.getRecipient().trim());
+        } else if (!StringUtils.hasText(entity.getRecipient())) {
+            // mkt_mail_message.recipient 非空;草稿允许先空串
+            entity.setRecipient("");
+        }
+        entity.setRecipientCc(joinEmails(dto.getRecipientCcList()));
+        entity.setRecipientBcc(joinEmails(dto.getRecipientBccList()));
+        entity.setContent(dto.getContent());
+        entity.setIsTrack(dto.getIsTrack() == null ? 0 : dto.getIsTrack());
+        if (entity.getOpened() == null) {
+            entity.setOpened(0);
+        }
+        if (entity.getOpenCount() == null) {
+            entity.setOpenCount(0);
+        }
+        entity.setOwnerBy(operatorId);
+        if (entity.getId() == null) {
+            entity.setCreateBy(operatorId);
+        }
+        entity.setIsDelete(CommonConstant.DEL_FLAG_0);
+        entity.setEnabled(Boolean.TRUE);
+    }
+
+    private void ensureTrackToken(MktMailMessageEntity entity, Integer isTrack) {
+        int track = isTrack == null ? (entity.getIsTrack() == null ? 0 : entity.getIsTrack()) : isTrack;
+        entity.setIsTrack(track);
+        if (track == 1 && !StringUtils.hasText(entity.getTrackToken())) {
+            entity.setTrackToken(UUID.randomUUID().toString().replace("-", ""));
+        }
+        if (track != 1) {
+            entity.setTrackToken(null);
+        }
+    }
+
     private SmtpPopSettingsEntity decryptAccount(SmtpPopSettingsEntity account) {
         AccessKeyEncryptor encryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
         account.setEmailPassword(encryptor.decrypt(account.getEmailPassword()));

+ 17 - 0
storlead-mail/storlead-mail-mkt/src/main/resources/mapper/MktMailAttachmentMapper.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.storlead.sales.mail.mkt.mapper.MktMailAttachmentMapper">
+
+    <resultMap id="BaseResultMap" type="com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity"
+               extends="com.storlead.framework.mybatis.mapper.SysBaseFieldMapper.BaseResultMap">
+        <id column="id" property="id"/>
+        <result column="mail_id" property="mailId"/>
+        <result column="account_id" property="accountId"/>
+        <result column="file_code" property="fileCode"/>
+        <result column="file_name" property="fileName"/>
+        <result column="file_path" property="filePath"/>
+        <result column="file_size" property="fileSize"/>
+        <result column="file_ext" property="fileExt"/>
+    </resultMap>
+
+</mapper>