发票下载邮箱发送服务技术方案
一、问题背景
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 更新任务状态:处理中 → 成功/失败 │
└─────────────────────────────────────────────────────────────────┘
核心设计思想:
- 异步解耦:用户提交后立即返回,后台异步处理
- 任务状态管理:数据库记录任务状态,用户可查询进度
- 消息队列:使用RabbitMQ确保任务不丢失
- 手动ACK:确保消息可靠处理
- 失败重试:邮件发送失败自动重试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 最佳实践建议
- 监控先行:上线前配置好监控和告警
- 灰度发布:先小流量验证,再全量上线
- 预案准备:准备降级方案(如降级为手动重发)
- 定期清理:定期清理已完成的历史任务
- 压测验证:上线前进行压力测试,验证系统承载能力
附录:核心代码清单
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);
}

浙公网安备 33010602011771号