发票下载邮箱发送服务技术方案

一、问题背景

1.1 业务场景

发票邮箱发送是发票批量下载场景的重要补充,主要解决以下问题:

  • 大批量发票下载:当发票数量超过批量下载限制时(如超过150张)
  • 异步处理需求:用户提交后无需等待,处理完成后通过邮件通知
  • 长期存档需求:用户需要保存发票文件作为财务凭证
  • 网络不稳定场景:网络条件差时,直接下载容易中断

典型使用场景

  • 月度/季度发票汇总:用户需要导出整月或整季的发票
  • 跨系统传输:发票数据需要传输到其他财务系统
  • 定时任务:系统定期自动发送发票到用户邮箱

1.2 核心挑战

挑战类型 具体表现 后果
处理耗时 大量发票下载+打包+邮件发送可能超过10分钟 同步请求超时
资源占用 邮件发送占用网络带宽和线程资源 其他功能受影响
任务丢失风险 服务重启导致任务丢失 用户体验差
重复提交风险 用户重复点击提交按钮 产生冗余任务
附件大小限制 邮件服务商对附件大小有限制 发送失败
邮件发送失败 网络问题或邮件服务器故障 用户收不到邮件

1.3 传统方案的问题

// ❌ 同步方案的问题
@PostMapping("/email/send")
public Result sendEmail() {
    // 1. 下载发票(耗时3分钟)
    List<Invoice> invoices = downloadInvoices();

    // 2. 打包ZIP(耗时1分钟)
    File zipFile = packageToZip(invoices);

    // 3. 发送邮件(耗时2分钟)
    emailSender.send(zipFile);

    // 总耗时:6分钟,用户需等待6分钟才能看到响应
    return Result.success();
}

问题分析

  • 用户体验差:需等待6-10分钟才能看到响应
  • 资源占用高:长时间占用HTTP连接和线程
  • 无任务记录:无法查询任务进度和历史记录
  • 无重试机制:邮件发送失败后无法自动重试
  • 服务重启风险:处理过程中服务重启导致任务丢失

二、技术方案设计

2.1 整体架构

采用 异步消息队列 + 任务状态管理 + 邮件发送 的架构:

┌─────────────────────────────────────────────────────────────────┐
│                        1. Controller 层                         │
│  用户提交请求 → 参数校验 → 邮箱校验 → 任务去重检查                │
│  保存任务记录 → 发送MQ消息 → 立即返回任务ID                       │
└────────────────────┬────────────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────────────┐
│                      2. RabbitMQ 消息队列                      │
│  交换机:invoice.email.task.exchange                            │
│  队列:invoice.email.task.queue                                │
│  路由键:invoice.download.email.send                           │
│  消息持久化:是                                                  │
│  ACK模式:手动确认                                              │
└────────────────────┬────────────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────────────┐
│                      3. Consumer 消费者                         │
│  监听队列 → 接收消息 → 调用Service处理                          │
│  处理成功 → 手动ACK                                             │
│  处理失败 → 手动ACK(避免无限重试)                             │
└────────────────────┬────────────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────────────┐
│                    4. Service 处理层                           │
│  4.1 更新任务状态:排队中 → 处理中                              │
│  4.2 查询发票列表                                               │
│  4.3 批量下载并打包为ZIP                                       │
│  4.4 发送邮件(带ZIP附件)                                     │
│  4.5 更新任务状态:处理中 → 成功/失败                           │
└─────────────────────────────────────────────────────────────────┘

核心设计思想

  1. 异步解耦:用户提交后立即返回,后台异步处理
  2. 任务状态管理:数据库记录任务状态,用户可查询进度
  3. 消息队列:使用RabbitMQ确保任务不丢失
  4. 手动ACK:确保消息可靠处理
  5. 失败重试:邮件发送失败自动重试3次

2.2 技术栈对比

技术点 方案A(传统同步) 方案B(本方案) 选择理由
处理方式 同步处理,用户等待 异步处理,立即返回 用户体验更好
任务调度 无任务队列 RabbitMQ消息队列 任务不丢失,支持重试
状态管理 无状态记录 数据库记录任务状态 用户可查询进度
邮件发送 发送失败直接报错 自动重试3次 提高成功率
资源占用 长时间占用连接 快速释放连接 资源利用率高
可扩展性 单机处理 消费者可横向扩展 支持高并发

2.3 关键配置参数

spring:
  # ===== 邮件服务配置 =====
  mail:
    host: smtp.example.com          # SMTP服务器地址
    port: 465                       # SMTP端口(SSL)
    username: noreply@example.com   # 发件人邮箱
    password: ${MAIL_PASSWORD}      # 邮箱密码(环境变量)
    protocol: smtps                 # 使用SMTPS协议
    from-name: "企业管理系统"        # 发件人显示名称
    properties:
      mail:
        smtp:
          auth: true
          ssl:
            enable: true
          connectiontimeout: 10000  # 连接超时10秒
          timeout: 30000             # 读取超时30秒
          writetimeout: 30000        # 写入超时30秒

    # 附件大小限制
    max-attachment-size: 50         # 最大50MB(与Tomcat保持一致)

    # 电子发票邮件发送配置
    max-invoice-email-send-count: 300  # 单次最多发送300张
    invoice-batch-size: 300            # 单批发送300张
    invoice-enable-batch: true          # 启用分批发送
    invoice-retry-count: 3              # 邮件发送重试3次

# ===== RabbitMQ 配置 =====
rabbitmq:
  host: localhost
  port: 5672
  username: guest
  password: guest
  listener:
    simple:
      acknowledge-mode: manual      # 手动ACK模式
      prefetch: 1                   # 每次只取1条消息(保证顺序处理)
      retry:
        enabled: false              # 关闭自动重试(手动控制)

参数设计说明

  • 附件限制50MB:与Tomcat的max-request-size保持一致
  • 单批300张发票:50MB限制下可安全发送约300张(150KB/张 × 300 = 45MB)
  • 重试3次:平衡成功率和系统负载
  • 手动ACK:确保消息可靠处理,避免任务丢失
  • Prefetch=1:保证任务顺序处理

三、核心实现

3.1 任务提交流程

3.1.1 Controller 层实现

@PostMapping("/email")
public Result<InvoiceEmailTaskSubmitResult> submitEmailTask(@RequestBody TaxTicketPageQuery query) {
    log.info("开始提交发票下载邮件发送任务,查询条件:{}", query);

    // 1. 发票下载邮箱发送前置校验,并获取有效发票下载数
    Pair<Integer, Integer> invoiceCountPair = taxTicketService.checkInvoiceDownloadInfo(
        query,
        mailProperties.getMaxInvoiceEmailSendCount()
    );

    // 2. 提交发票下载并邮箱发送任务到任务队列消息中
    InvoiceEmailTaskSubmitResult result = invoiceEmailTaskService.submitEmailTask(query);

    // 3. 设置返回结果
    result.setSubmitInvoiceCount(invoiceCountPair.getLeft());
    result.setValidInvoiceCount(invoiceCountPair.getRight());
    result.setInvalidInvoiceCount(invoiceCountPair.getLeft() - invoiceCountPair.getRight());

    return Result.success(result);
}

3.1.2 Service 层实现

@Override
@Transactional(rollbackFor = Exception.class)
public InvoiceEmailTaskSubmitResult submitEmailTask(TaxTicketPageQuery query) {
    // 1. 获取用户信息
    UserInfoDTO user = UserContextHolder.getUser();
    String userId = user.getOid();
    String email = user.getEmail();

    // 2. 邮箱合法性校验
    validateEmail(email, user.getCompanyUser());

    // 3. 构建任务唯一标识
    String queryJson = objectMapper.writeValueAsString(query);
    String uniqueKey = buildTaskUniqueKey(queryJson, email, userId);

    // 4. 任务去重检查(当天)
    if (checkCurrentDayDuplicateTask(uniqueKey, userId, email)) {
        throw new BusinessException("存在相同发票下载邮箱发送任务正在执行,无须重复提交");
    }

    // 5. 保存任务主表记录
    InvoiceEmailTask task = new InvoiceEmailTask();
    task.setUserId(userId);
    task.setRecipientEmail(email);
    task.setTaskStatus(InvoiceEmailTaskStatusEnum.QUEUED);
    task.setSubmitTime(LocalDateTime.now());
    task.setTaskUniqueKey(uniqueKey);
    taskMapper.insert(task);

    // 6. 保存任务详情表记录
    InvoiceEmailTaskDetail detail = new InvoiceEmailTaskDetail();
    detail.setId(task.getId());
    detail.setQueryJson(queryJson);
    detailMapper.insert(detail);

    // 7. 发送MQ消息
    InvoiceEmailSendDTO dto = new InvoiceEmailSendDTO();
    dto.setTaskId(task.getId());
    dto.setQuery(query);
    dto.setRecipientEmail(email);

    rabbitMQSender.send(
        INVOICE_EMAIL_TASK_EXCHANGE,
        INVOICE_EMAIL_TASK_ROUTING_KEY,
        dto
    );

    // 8. 返回结果
    InvoiceEmailTaskSubmitResult result = new InvoiceEmailTaskSubmitResult();
    result.setTaskId(task.getId());
    result.setRecipientEmail(email);
    result.setTaskStatus(InvoiceEmailTaskStatusEnum.QUEUED);

    return result;
}

3.2 任务去重机制

3.2.1 唯一标识生成

@Override
public String buildTaskUniqueKey(String queryJson, String email, String userId) {
    // 组合查询条件 + 邮箱 + 用户ID,计算hashCode
    String combined = queryJson + email + userId;
    return String.valueOf(combined.hashCode());
}

去重逻辑

  • 查询条件相同(查询的发票范围相同)
  • 收件人邮箱相同
  • 用户ID相同
  • 当天存在状态为"排队中"或"处理中"的任务

3.2.2 去重检查

@Override
public boolean checkCurrentDayDuplicateTask(String uniqueKey, String userId, String recipientEmail) {
    LocalDate today = LocalDate.now();
    LocalDateTime startTime = today.atStartOfDay();
    LocalDateTime endTime = today.atTime(LocalTime.MAX);

    Long count = taskMapper.selectCount(
        new LambdaQueryWrapper<InvoiceEmailTask>()
            .eq(InvoiceEmailTask::getTaskUniqueKey, uniqueKey)
            .eq(InvoiceEmailTask::getUserId, userId)
            .eq(InvoiceEmailTask::getRecipientEmail, recipientEmail)
            .in(InvoiceEmailTask::getTaskStatus,
                InvoiceEmailTaskStatusEnum.QUEUED,
                InvoiceEmailTaskStatusEnum.PROCESSING)
            .ge(InvoiceEmailTask::getSubmitTime, startTime)
            .le(InvoiceEmailTask::getSubmitTime, endTime)
    );

    return count != null && count > 0;
}

3.3 RabbitMQ 消息消费

3.3.1 消费者实现

@RabbitListener(queues = INVOICE_EMAIL_TASK_QUEUE)
public void handleInvoiceEmailTask(Message message, Channel channel, InvoiceEmailSendDTO dto) {
    long deliveryTag = message.getMessageProperties().getDeliveryTag();

    try {
        // 1. 处理发票下载邮件发送任务
        invoiceDownloadService.processEmailTask(dto);

        // 2. 处理成功,手动ACK
        channel.basicAck(deliveryTag, false);

    } catch (Exception e) {
        log.error("发票邮件发送任务处理失败: taskId={}, error={}",
            dto.getTaskId(), e.getMessage(), e);

        // 3. 处理失败,仍手动ACK(避免无限重试)
        channel.basicAck(deliveryTag, false);
    }
}

ACK策略说明

场景 ACK策略 原因
处理成功 basicAck 确认消息已处理
处理失败 basicAck 避免无限重试导致系统负载过高
消息格式错误 basicAck 重试无意义,直接丢弃

为什么不使用 basicNack + requeue

  • ❌ 邮件发送失败通常是永久性失败(邮箱不存在、附件过大等)
  • ❌ 重试会导致大量失败消息积压队列
  • ✅ 失败记录在数据库中,用户可重新提交

3.4 任务处理流程

3.4.1 任务处理主流程

@Override
public void processEmailTask(InvoiceEmailSendDTO dto) throws Exception {
    Long taskId = dto.getTaskId();
    log.info("开始处理发票邮件发送任务,任务ID:{}", taskId);

    // 1. 查询任务详情
    InvoiceEmailTask task = invoiceEmailTaskService.getTaskWithDetail(taskId);
    if (task == null) {
        log.warn("任务不存在,任务ID:{}", taskId);
        return;
    }

    // 2. 幂等校验:检查任务状态
    if (task.getTaskStatus() != InvoiceEmailTaskStatusEnum.QUEUED) {
        log.warn("任务状态不是排队中,任务ID:{},当前状态:{}",
            taskId, task.getTaskStatus());
        return;
    }

    // 3. 更新任务状态为处理中
    LocalDateTime processStartTime = LocalDateTime.now();
    invoiceEmailTaskService.updateTaskToProcessing(taskId, processStartTime);

    // 4. 查询发票信息列表
    List<InvoiceDownloadInfoVO> invoiceList = taxTicketService.getValidInvoiceDownloadInfoList(dto.getQuery());
    if (invoiceList == null || invoiceList.isEmpty()) {
        invoiceEmailTaskService.updateTaskToFailed(taskId, 0, "所需下载发票列表查询为空");
        return;
    }

    int invoiceCount = invoiceList.size();

    // 5. 批量下载并打包为ZIP
    File tempZipFile = File.createTempFile("invoice-", ".zip");
    tempZipFile.deleteOnExit();

    try (FileOutputStream fos = new FileOutputStream(tempZipFile)) {
        Integer successCount = invoiceDownloadComponent.batchDownloadAndPack(invoiceList, fos);

        // 6. 发送邮件
        LocalDateTime sendStartTime = LocalDateTime.now();
        String subject = emailSender.generateSubjectWithTimestamp("电子发票下载");

        String content = String.format("""
            <html>
                <body>
                    <p>尊敬的用户,您好!</p>
                    <p style="margin-left: 2em;">欢迎使用企业管理系统进行发票下载邮箱发送服务,附件为您所需下载发票zip压缩包,合计下载【%s】张电子发票,请注意查收。</p>
                    <p>如有任何疑问,请联系客服咨询,此邮件为系统自动发送,请勿回复。</p>
                </body>
            </html>
            """, invoiceCount);

        emailSender.sendInvoiceZipEmail(
            task.getRecipientEmail(),
            subject,
            content,
            tempZipFile,
            invoiceCount
        );

        // 7. 更新任务状态为成功
        LocalDateTime processEndTime = LocalDateTime.now();
        long duration = Duration.between(processStartTime, processEndTime).toMillis();

        invoiceEmailTaskService.updateTaskToSuccess(
            taskId, sendStartTime, processEndTime, duration,
            invoiceCount, successCount, invoiceCount - successCount
        );

    } catch (Exception e) {
        // 8. 异常处理:更新任务状态为失败
        invoiceEmailTaskService.updateTaskToFailed(taskId, invoiceCount, "任务处理失败:" + e.getMessage());
    } finally {
        // 9. 删除临时文件
        if (tempZipFile.exists()) {
            tempZipFile.delete();
        }
    }
}

3.4.2 临时文件处理机制

为什么使用临时文件?

在发票下载并发送到邮件的流程中,需要将下载的发票PDF打包成ZIP文件。这里采用了临时文件的方式,而不是在内存中完成整个流程。

实现方式

// 1. 创建临时文件
File tempZipFile = File.createTempFile("invoice-", ".zip");
tempZipFile.deleteOnExit();  // JVM退出时自动删除

try (FileOutputStream fos = new FileOutputStream(tempZipFile)) {
    // 2. 下载发票并直接写入临时文件
    Integer successCount = invoiceDownloadComponent.batchDownloadAndPack(invoiceList, fos);

    // 3. 发送邮件(从临时文件读取附件)
    emailSender.sendInvoiceZipEmail(email, subject, content, tempZipFile, invoiceCount);

} finally {
    // 4. 无论成功或失败,都删除临时文件
    if (tempZipFile.exists()) {
        if (tempZipFile.delete()) {
            log.info("临时ZIP文件已删除:{}", tempZipFile.getAbsolutePath());
        } else {
            log.warn("临时ZIP文件删除失败:{}", tempZipFile.getAbsolutePath());
        }
    }
}

临时文件机制的优势

┌────────────────────────────────────────────────────────────┐
│              临时文件 vs 内存处理对比                        │
├────────────────────────────────────────────────────────────┤
│ 【内存处理方式】(不推荐)                                  │
│   1. 所有PDF数据在内存中                                    │
│   2. ZIP也在内存中生成                                      │
│   3. 邮件发送需要读取内存中的ZIP                            │
│   4. 内存峰值 = PDF数据 + ZIP数据 + 邮件缓冲                │
│   5. 300张发票约占用 150MB + 45MB + 45MB = 240MB            │
│                                                                │
│ 【临时文件方式】(本方案)                                  │
│   1. PDF数据下载后直接写入磁盘                              │
│   2. ZIP在磁盘上生成(流式写入)                            │
│   3. 邮件发送从磁盘文件读取                                 │
│   4. 内存峰值 = 队列缓冲 + 邮件缓冲 ≈ 90MB                  │
│   5. 处理完成后立即删除,不占用磁盘空间                     │
└────────────────────────────────────────────────────────────┘

核心优势

优势 说明 效果
降低内存占用 ZIP文件不占用JVM堆内存 内存占用降低60%
避免OOM风险 大文件不在内存中累积 系统更稳定
简化邮件发送 JavaMailSender直接支持File附件 代码更简洁
便于调试 可检查临时文件内容 问题排查更容易
资源清理可控 finally块确保文件被删除 无磁盘泄漏风险
支持大附件 不受JVM堆内存限制 可发送更大附件

为什么不直接流式发送到邮件?

┌────────────────────────────────────────────────────────────┐
│          为什么不能直接流式发送邮件?                        │
├────────────────────────────────────────────────────────────┤
│ ❌ 技术限制:                                               │
│   1. JavaMailSender不支持直接从InputStream构建附件         │
│   2. 必须提供File对象或byte[]                              │
│   3. 如果用byte[]需要整个文件加载到内存                    │
│                                                                │
│ ❌ 邮件协议限制:                                           │
│   1. SMTP协议要求附件大小已知(Content-Length)            │
│   2. 流式生成时无法提前知道ZIP大小                          │
│   3. 邮件服务器可能拒绝未知大小的附件                       │
│                                                                │
│ ❌ 重试困难:                                               │
│   1. 如果邮件发送失败需要重试                               │
│   2. 流式生成需要重新下载所有发票                           │
│   3. 有临时文件可以直接重试发送邮件                         │
└────────────────────────────────────────────────────────────┘

临时文件安全性保障

安全措施 实现方式 作用
系统临时目录 File.createTempFile() 使用系统临时目录 避免污染应用目录
随机文件名 自动生成随机文件名(如invoice-12345.zip 防止文件名冲突
自动删除 deleteOnExit() + finally 双重保障 确保文件被清理
权限控制 临时文件默认只有当前用户可读写 防止其他进程访问
定期清理 虽有自动删除,但也可配置定时任务清理遗留文件 容错机制

异常处理保障

finally {
    // 无论成功、失败、异常,都确保临时文件被删除
    if (tempZipFile.exists()) {
        boolean deleted = tempZipFile.delete();
        if (!deleted) {
            // 记录警告,但不影响任务状态
            log.warn("临时文件删除失败,将在JVM退出时自动删除:{}",
                tempZipFile.getAbsolutePath());
        }
    }
}

资源清理流程

┌────────────────────────────────────────────────────────────┐
│                  临时文件生命周期                             │
├────────────────────────────────────────────────────────────┤
│ 1. 创建:File.createTempFile("invoice-", ".zip")           │
│    → 生成文件:/tmp/invoice-1234567890.zip                  │
│    → 设置deleteOnExit(true)                                 │
│                                                                │
│ 2. 使用:                                                   │
│    → 下载发票并写入文件                                     │
│    → 打包成ZIP                                             │
│    → 发送邮件(读取文件)                                   │
│                                                                │
│ 3. 清理(优先级):                                         │
│    → finally块中调用delete()(优先清理)                   │
│    → 如果finally失败,JVM退出时自动删除                     │
│                                                                │
│ 4. 容错:                                                   │
│    → 定时任务定期清理/tmp目录下超过24小时的临时文件         │
│    → 确保即使在异常情况下也不会累积垃圾文件                 │
└────────────────────────────────────────────────────────────┘

3.5 邮件发送组件

3.5.1 发票邮件发送方法

public void sendInvoiceZipEmail(String to, String subject, String content,
        File zipFile, int invoiceCount) throws MessagingException {

    log.info("开始发送电子发票压缩包邮件: to={}, fileName={}, invoiceCount={}",
        to, zipFile.getName(), invoiceCount);

    try {
        // 1. 校验文件格式
        validateZipFileFormat(zipFile);

        // 2. 校验附件大小
        validateAttachmentSize(zipFile);

        // 3. 创建邮件消息
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());

        helper.setFrom(mailProperties.getFromEmail(), mailProperties.getFromName());
        helper.setTo(to);
        helper.setSubject(subject);
        helper.setText(content, true);  // HTML格式

        // 4. 添加ZIP附件
        helper.addAttachment(zipFile.getName(), zipFile);

        // 5. 发送邮件
        mailSender.send(message);

        log.info("电子发票压缩包邮件发送成功: to={}, fileName={}, fileSize={}",
            to, zipFile.getName(), zipFile.length());

    } catch (MessagingException e) {
        log.error("电子发票邮件发送失败: to={}, fileName={}, error={}",
            to, zipFile.getName(), e.getMessage());
        throw e;
    }
}

3.5.2 校验方法

// 校验附件大小
private void validateAttachmentSize(File file) throws MessagingException {
    if (file == null || !file.exists()) {
        throw new MessagingException("附件文件不存在");
    }

    long fileSize = file.length();
    long maxSize = mailProperties.getMaxAttachmentSizeInBytes();

    if (fileSize > maxSize) {
        String sizeInfo = String.format("%.2fMB", fileSize / 1024.0 / 1024.0);
        String maxSizeInfo = String.format("%.2fMB", maxSize / 1024.0 / 1024.0);
        throw new MessagingException(
            String.format("附件大小超过限制:当前大小%s,最大允许%s", sizeInfo, maxSizeInfo)
        );
    }
}

// 校验ZIP文件格式
private void validateZipFileFormat(File file) throws MessagingException {
    if (file == null || !file.exists()) {
        throw new MessagingException("文件不存在");
    }

    String fileName = file.getName().toLowerCase();
    if (!fileName.endsWith(".zip")) {
        throw new MessagingException(
            "电子发票压缩包只支持.zip格式,当前文件:" + file.getName()
        );
    }
}

3.5.3 重试机制

// 带重试机制的邮件发送
private void sendEmailWithRetry(MailAction mailAction, int retryCount,
        int batch, int totalBatches) throws MessagingException {
    Exception lastException = null;

    for (int attempt = 1; attempt <= retryCount; attempt++) {
        try {
            mailAction.send();
            if (attempt > 1) {
                log.info("邮件发送重试成功: batch={}/{}, attempt={}",
                    batch, totalBatches, attempt);
            }
            return;

        } catch (MessagingException e) {
            lastException = e;
            log.warn("邮件发送失败(第{}次尝试): batch={}/{}", attempt, batch, totalBatches);

            // 等待后重试(递增等待时间:1秒、2秒、3秒)
            if (attempt < retryCount) {
                Thread.sleep(1000L * attempt);
            }
        }
    }

    // 所有重试都失败
    throw new MessagingException("邮件发送失败(已重试" + retryCount + "次)", lastException);
}

3.6 任务状态管理

3.6.1 任务状态枚举

@Getter
@AllArgsConstructor
public enum InvoiceEmailTaskStatusEnum {
    QUEUED(0, "排队中"),
    PROCESSING(1, "处理中"),
    SUCCESS(2, "发送成功"),
    FAILED(3, "发送失败");

    @EnumValue
    private final Integer code;

    @JsonValue
    private final String desc;
}

3.6.2 状态更新方法

// 更新为处理中
@Transactional(rollbackFor = Exception.class)
public void updateTaskToProcessing(Long taskId, LocalDateTime startTime) {
    InvoiceEmailTask task = new InvoiceEmailTask();
    task.setId(taskId);
    task.setTaskStatus(InvoiceEmailTaskStatusEnum.PROCESSING);
    task.setDownloadStartTime(startTime);
    taskMapper.updateById(task);
}

// 更新为成功
@Transactional(rollbackFor = Exception.class)
public void updateTaskToSuccess(Long taskId, LocalDateTime sendStartTime,
        LocalDateTime processEndTime, Long duration, Integer invoiceCount,
        Integer successCount, Integer failCount) {
    InvoiceEmailTask task = new InvoiceEmailTask();
    task.setId(taskId);
    task.setTaskStatus(InvoiceEmailTaskStatusEnum.SUCCESS);
    task.setSendSuccessTime(sendStartTime);
    task.setEndTime(processEndTime);
    task.setDuration(duration);
    task.setInvoiceCount(invoiceCount);
    task.setSuccessCount(successCount);
    task.setFailCount(failCount);
    taskMapper.updateById(task);
}

// 更新为失败
@Transactional(rollbackFor = Exception.class)
public void updateTaskToFailed(Long taskId, Integer invoiceCount, String errorMessage) {
    InvoiceEmailTask task = new InvoiceEmailTask();
    task.setId(taskId);
    task.setInvoiceCount(invoiceCount);
    task.setSuccessCount(0);
    task.setFailCount(invoiceCount);
    task.setTaskStatus(InvoiceEmailTaskStatusEnum.FAILED);
    task.setEndTime(LocalDateTime.now());
    taskMapper.updateById(task);

    // 记录错误信息到详情表
    InvoiceEmailTaskDetail detail = new InvoiceEmailTaskDetail();
    detail.setId(taskId);
    detail.setErrorMessage(errorMessage);
    detailMapper.updateById(detail);
}

四、安全性设计

4.1 邮箱安全校验

4.1.1 邮箱格式校验

private void validateEmail(String email, Boolean isCompanyUser) {
    // 1. 邮箱为空检查
    if (email == null || email.trim().isEmpty()) {
        String tip = isCompanyUser
            ? "企业信息系统备案的邮箱未配置,请检查!"
            : "权限管理系统用户备案的邮箱未配置,请检查!";
        throw new BusinessException(tip);
    }

    // 2. 邮箱格式校验
    String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
    if (!email.matches(emailRegex)) {
        String tip = isCompanyUser
            ? "企业信息系统备案的邮箱格式不正确,请检查!"
            : "权限管理系统用户备案的邮箱格式不正确,请检查!";
        throw new BusinessException(tip);
    }
}

校验规则

  • ✅ 标准邮箱格式:user@example.com
  • ✅ 支持子域名:user@mail.example.com
  • ✅ 支持特殊字符:. + _ -
  • ❌ 拒绝空邮箱
  • ❌ 拒绝格式错误邮箱

4.2 附件大小限制

4.2.1 多层限制机制

┌────────────────────────────────────────────────────────────┐
│                  附件大小限制层级                           │
├────────────────────────────────────────────────────────────┤
│ 1. Tomcat 层(spring.servlet.multipart.max-request-size)  │
│    限制:50MB                                               │
│    作用:防止大文件上传攻击                                  │
│                                                                │
│ 2. MailProperties 层(spring.mail.max-attachment-size)    │
│    限制:50MB                                               │
│    作用:配置化控制,与Tomcat保持一致                        │
│                                                                │
│ 3. EmailSender 层(validateAttachmentSize)                │
│    限制:50MB                                               │
│    作用:运行时校验,提供友好错误提示                        │
└────────────────────────────────────────────────────────────┘

为什么限制50MB

  • 大多数邮件服务商限制附件在20-50MB
  • 附件过大容易被识别为垃圾邮件
  • 50MB可容纳约300张发票(150KB/张),满足日常需求

4.3 文件格式校验

private void validateZipFileFormat(File file) throws MessagingException {
    if (file == null || !file.exists()) {
        throw new MessagingException("文件不存在");
    }

    String fileName = file.getName().toLowerCase();
    if (!fileName.endsWith(".zip")) {
        throw new MessagingException(
            "电子发票压缩包只支持.zip格式,当前文件:" + file.getName()
        );
    }
}

安全考虑

  • ✅ 防止上传恶意文件(如.exe、.sh)
  • ✅ 确保邮件客户端可以正常打开
  • ✅ 统一格式,便于用户识别

4.4 敏感信息保护

邮件内容安全规范

信息类型 是否允许 原因
发票PDF文件 ✅ 允许 用户所需的业务数据
发票数量统计 ✅ 允许 用户需要知道发票数量
时间范围 ✅ 允许 用户需要知道发票时间范围
用户密码 ❌ 禁止 敏感信息,不应出现在邮件中
手机号/身份证 ❌ 禁止 个人隐私信息
系统内部路径 ❌ 禁止 可能泄露系统架构信息

五、性能分析

5.1 处理时间分析

5.1.1 各阶段耗时

假设处理 300张发票

阶段 耗时 说明
任务提交 <100ms 数据库插入 + MQ发送
任务队列等待 0-60秒 取决于队列中任务数量
查询发票列表 1-2秒 数据库查询
下载发票 45秒 20线程并发下载(300/20×3秒)
打包ZIP 3秒 流式打包
发送邮件 5-15秒 取决于网络和邮件服务器
总耗时 55-65秒 用户可见时间

5.1.2 用户体验对比

对比项 同步方案 异步方案
响应时间 60-65秒 <100ms
用户体验 需等待1分钟 即刻返回
超时风险 高(容易超时) 无(后台处理)
进度查询 不支持 支持

5.2 并发处理能力

5.2.1 单消费者吞吐量

┌────────────────────────────────────────────────────────────┐
│                 单消费者处理能力分析                        │
├────────────────────────────────────────────────────────────┤
│ 单任务平均耗时:60秒                                        │
│                                                                │
│ 单消费者每分钟处理数 = 60秒 ÷ 60秒 = 1个任务/分钟         │
│ 单消费者每小时处理数 = 60分钟 × 1 = 60个任务/小时         │
│                                                                │
│ 假设平均每个任务200张发票:                                 │
│ 单消费者每小时处理发票数 = 60 × 200 = 12,000张/小时       │
└────────────────────────────────────────────────────────────┘

5.2.2 多消费者扩展

消费者数 每小时处理任务数 每小时处理发票数(平均200张/任务)
1个 60个 12,000张
2个 120个 24,000张
3个 180个 36,000张
5个 300个 60,000张

水平扩展建议

  • 低并发(<10任务/小时):1个消费者
  • 中并发(10-50任务/小时):2-3个消费者
  • 高并发(>50任务/小时):5+个消费者

5.3 资源占用分析

5.3.1 内存占用

单个任务内存占用

┌────────────────────────────────────────────────────────────┐
│                  单任务内存占用组成                         │
├────────────────────────────────────────────────────────────┤
│ 1. WebClient 缓冲区:32MB(固定)                           │
│ 2. 发票数据(队列中):300张 × 150KB ≈ 45MB                │
│ 3. ZIP写入缓冲:约150KB(单个PDF)                          │
│ 4. 临时ZIP文件:约45MB(磁盘文件)                          │
│ 5. 线程栈:20线程 × 1MB = 20MB                             │
│ 6. 其他对象:约50MB                                         │
├────────────────────────────────────────────────────────────┤
│ JVM堆内存峰值:约 150MB                                     │
│ 磁盘占用:约 45MB(临时文件)                               │
└────────────────────────────────────────────────────────────┘

并发场景内存占用(3个消费者同时处理):

峰值内存 = 150MB × 3 = 450MB
假设JVM堆内存为4GB:
实际内存占用率 = 450MB / 4096MB ≈ 11%

结论:内存占用极低,完全安全

5.3.2 网络带宽占用

单个任务网络带宽

阶段 带宽占用 持续时间
下载发票 1MB/s 45秒
发送邮件 3MB/s 10秒
平均带宽 1.5MB/s 总时长55秒

并发场景网络带宽(3个消费者同时处理):

峰值带宽 = 3MB/s × 3 = 9MB/s = 72Mbps

建议网络配置

  • 服务器带宽:≥100Mbps
  • 支持并发处理:≥3个任务

5.4 数据库压力分析

5.4.1 数据库操作次数

单个任务

操作类型 次数 说明
INSERT(主表) 1次 提交任务时
INSERT(详情表) 1次 提交任务时
SELECT(查询任务) 1次 消费时
UPDATE(处理中) 1次 开始处理时
UPDATE(成功/失败) 1次 完成处理时
合计 5次 读写均衡

5.4.2 并发场景数据库压力

并发任务数 每分钟数据库操作数 说明
1个 5次 压力极小
3个 15次 正常范围
5个 25次 正常范围

结论:数据库压力小,无需特殊优化


六、生产实践

6.1 监控指标

6.1.1 核心监控指标

指标类型 监控项 告警阈值 说明
性能指标 平均任务处理时间 >120秒 单个任务超过2分钟告警
性能指标 P95任务处理时间 >180秒 95分位超过3分钟告警
性能指标 邮件发送失败率 >5% 失败率超过5%告警
队列指标 队列深度 >100 队列积压超过100告警
队列指标 消息消费速率 <1个/分钟 消费速度过慢告警
资源指标 临时文件数量 >10个 清理机制可能失效
业务指标 任务提交数 监控趋势 了解业务负载
业务指标 平均发票数 监控趋势 了解用户行为

6.1.2 日志记录

关键日志点

// 1. 任务提交
log.info("提交发票邮件发送任务,用户ID:{},邮箱:{}", userId, email);

// 2. 任务开始处理
log.info("开始处理发票邮件发送任务,任务ID:{}", taskId);

// 3. 发票下载进度
if (processedCount % 30 == 0) {
    log.info("下载进度: {}/{}", processedCount, totalCount);
}

// 4. 邮件发送成功
log.info("邮件发送成功,收件人:{},任务ID:{}", email, taskId);

// 5. 任务完成
log.info("任务执行完成,任务ID:{},总耗时:{}ms,成功:{}张",
    taskId, duration, successCount);

6.2 故障排查指南

6.2.1 常见问题与处理

问题现象 可能原因 排查步骤 解决方案
任务一直排队 消费者未启动 1. 检查消费者进程
2. 查看消费者日志
1. 启动消费者
2. 检查RabbitMQ连接
邮件发送失败 邮箱配置错误 1. 检查SMTP配置
2. 测试邮件发送
1. 更正配置
2. 重启服务
附件过大 发票数量过多 1. 查看发票数量
2. 检查ZIP大小
1. 分批发送
2. 调整批次大小
任务丢失 服务重启 1. 检查队列持久化
2. 查看任务记录
1. 确保队列持久化
2. 用户重新提交
重复任务 用户重复提交 1. 检查去重逻辑 1. 优化去重提示

6.2.2 邮件发送问题排查

问题:邮件发送失败率高

排查步骤

# 1. 检查SMTP连接
telnet smtp.example.com 465

# 2. 检查邮箱认证
# 查看应用日志中的认证错误

# 3. 检查附件大小
du -h *.zip

# 4. 测试邮件发送
# 使用测试账号手动发送邮件

常见错误及解决方案

错误信息 原因 解决方案
Authentication failed 邮箱账号或密码错误 检查配置,更新密码
Connection timeout SMTP服务器连接超时 检查网络,调整超时时间
Attachment too large 附件超过限制 分批发送或减小批次大小
Invalid recipient 收件人邮箱不存在 提示用户检查邮箱地址

6.3 优化建议

6.3.1 性能优化

优化方向 具体措施 效果
下载优化 增加下载线程数 提升下载速度
批次调整 根据附件大小动态调整批次 避免附件过大
邮件模板 预编译HTML模板 减少字符串拼接开销
连接复用 复用SMTP连接 减少连接建立时间

6.3.2 可靠性优化

优化方向 具体措施 效果
消息持久化 RabbitMQ队列持久化 服务重启不丢失任务
任务记录 数据库记录所有任务 支持任务查询和重试
失败重试 邮件发送失败自动重试 提高成功率
监控告警 实时监控任务状态 及时发现和处理问题

七、总结

7.1 技术方案优势

维度 优化点 效果
用户体验 异步处理,立即返回 响应时间从60秒降至<100ms
系统可靠性 消息队列 + 任务记录 任务不丢失,支持重试
可扩展性 消费者可横向扩展 支持高并发场景
可维护性 任务状态可查询 便于问题排查和运维
安全性 多重校验 + 去重机制 防止恶意提交和重复任务

7.2 关键配置总结

# 邮件服务配置
spring:
  mail:
    max-attachment-size: 50           # 附件最大50MB
    max-invoice-email-send-count: 300 # 单次最多300张
    invoice-retry-count: 3            # 重试3次

# RabbitMQ 配置
rabbitmq:
  listener:
    simple:
      acknowledge-mode: manual        # 手动ACK
      prefetch: 1                     # 保证顺序处理

7.3 最佳实践建议

  1. 监控先行:上线前配置好监控和告警
  2. 灰度发布:先小流量验证,再全量上线
  3. 预案准备:准备降级方案(如降级为手动重发)
  4. 定期清理:定期清理已完成的历史任务
  5. 压测验证:上线前进行压力测试,验证系统承载能力

附录:核心代码清单

A. Controller 层

@PostMapping("/email")
public Result<InvoiceEmailTaskSubmitResult> submitEmailTask(@RequestBody TaxTicketPageQuery query) {
    // 1. 前置校验
    Pair<Integer, Integer> invoiceCountPair = taxTicketService.checkInvoiceDownloadInfo(
        query, mailProperties.getMaxInvoiceEmailSendCount()
    );

    // 2. 提交任务
    InvoiceEmailTaskSubmitResult result = invoiceEmailTaskService.submitEmailTask(query);

    // 3. 设置返回结果
    result.setSubmitInvoiceCount(invoiceCountPair.getLeft());
    result.setValidInvoiceCount(invoiceCountPair.getRight());
    result.setInvalidInvoiceCount(invoiceCountPair.getLeft() - invoiceCountPair.getRight());

    return Result.success(result);
}

B. 任务提交核心逻辑

@Transactional(rollbackFor = Exception.class)
public InvoiceEmailTaskSubmitResult submitEmailTask(TaxTicketPageQuery query) {
    // 1. 获取用户信息
    UserInfoDTO user = UserContextHolder.getUser();
    String userId = user.getOid();
    String email = user.getEmail();

    // 2. 邮箱校验
    validateEmail(email, user.getCompanyUser());

    // 3. 构建唯一标识
    String queryJson = objectMapper.writeValueAsString(query);
    String uniqueKey = buildTaskUniqueKey(queryJson, email, userId);

    // 4. 去重检查
    if (checkCurrentDayDuplicateTask(uniqueKey, userId, email)) {
        throw new BusinessException("存在相同任务正在执行");
    }

    // 5. 保存任务
    InvoiceEmailTask task = new InvoiceEmailTask();
    task.setUserId(userId);
    task.setRecipientEmail(email);
    task.setTaskStatus(InvoiceEmailTaskStatusEnum.QUEUED);
    task.setTaskUniqueKey(uniqueKey);
    taskMapper.insert(task);

    // 6. 发送MQ消息
    InvoiceEmailSendDTO dto = new InvoiceEmailSendDTO();
    dto.setTaskId(task.getId());
    dto.setQuery(query);
    rabbitMQSender.send(EXCHANGE, ROUTING_KEY, dto);

    // 7. 返回结果
    InvoiceEmailTaskSubmitResult result = new InvoiceEmailTaskSubmitResult();
    result.setTaskId(task.getId());
    result.setRecipientEmail(email);
    return result;
}

C. RabbitMQ 消费者

@RabbitListener(queues = INVOICE_EMAIL_TASK_QUEUE)
public void handleInvoiceEmailTask(Message message, Channel channel, InvoiceEmailSendDTO dto) {
    long deliveryTag = message.getMessageProperties().getDeliveryTag();

    try {
        // 处理任务
        invoiceDownloadService.processEmailTask(dto);

        // 手动ACK
        channel.basicAck(deliveryTag, false);

    } catch (Exception e) {
        log.error("任务处理失败: taskId={}", dto.getTaskId(), e);

        // 仍手动ACK,避免无限重试
        channel.basicAck(deliveryTag, false);
    }
}

D. 邮件发送方法

public void sendInvoiceZipEmail(String to, String subject, String content,
        File zipFile, int invoiceCount) throws MessagingException {

    // 1. 校验文件
    validateZipFileFormat(zipFile);
    validateAttachmentSize(zipFile);

    // 2. 创建邮件
    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());

    helper.setFrom(mailProperties.getFromEmail(), mailProperties.getFromName());
    helper.setTo(to);
    helper.setSubject(subject);
    helper.setText(content, true);
    helper.addAttachment(zipFile.getName(), zipFile);

    // 3. 发送
    mailSender.send(message);
}
posted @ 2026-03-15 17:10  flycloudy  阅读(0)  评论(0)    收藏  举报