Spring Boot 3.5 + Spring Cloud Stream:邮件发送与幂等实战

0. 背景与目标

  • 目标:在微服务消息中心中实现“发送邮件验证码”的异步化处理:
    • Web 接口入参校验 → 发送 MailMessageSendEvent 到 RocketMQ → 消费者落库并真正调用邮件网关(QQ 邮箱)。
    • 需要解决:
    • 版本匹配与 Binder 正常工作;
    • 邮箱模板加载与渲染;
    • QQ 邮箱 SMTP 连接、鉴权、SSL 配置;
    • MQ 至少一次投递导致的重复消费问题(幂等);
    • 常见异常排查:表不存在、参数越界、SMTP 553、EOF、NPE 等。

1. 技术栈与版本矩阵

组件

版本

说明

JDK

21

与 Spring Framework 6.x 匹配

Spring Boot

3.5.3

Northfields 世代

Spring Cloud

2025.0.0

Northfields 主版本

Spring Cloud Alibaba

2023.0.3.3

与 Northfields 适配版本

RocketMQ

Client 5.3.x

与 SCA Binder 匹配

Spring Cloud Stream Binder RocketMQ

来自 SCA 2023.0.3.3

采用 function style(StreamBridge + Consumer)

MyBatis-Plus

*. *. .

数据访问

Jakarta Mail (Angus)

2.0.3

Spring Boot 3.x 默认走 Jakarta API

FreeMarker

2.3.x

模板引擎

经验:Northfields 下建议直接采用 SCA 2023.0.3.3,Binder 与 RocketMQ 5.3.x 组合比较顺畅。


2. 业务架构与消息流

2.1 架构总览(Mermaid)

2.2 调用时序(Mermaid Sequence)


3. Maven 依赖要点

仅列关键依赖与典型排除,完整 pom 视项目而定。

<dependencyManagement>
  <dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>3.5.3</version>
        <type>pom</type>
        <scope>import</scope>
    </dependency>
    <!-- Spring Cloud 2025 Northfields -->
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2025.0.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    <!-- Spring Cloud Alibaba 与 Northfields 适配版本 -->
    <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-alibaba-dependencies</artifactId>
      <version>2023.0.3.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
​
<dependencies>
  <!-- Spring Web / Validation -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
​
  <!-- Stream + RocketMQ Binder 来自 SCA -->
  <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
  </dependency>
​
  <!-- MyBatis Plus -->
  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>***.***.***.***</version>
  </dependency>
​
  <!-- 邮件(Jakarta Mail) -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
  </dependency>
​
  <!-- FreeMarker -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-freemarker</artifactId>
  </dependency>
​
  <!-- 其它:Lombok、Hutool、Guava、Fastjson2 等按需引入 -->
</dependencies>

4. 核心配置(application.yml)

4.1 Stream & RocketMQ Binder

spring:
  cloud:
    stream:
      function:
        definition: mailSend
      bindings:
        # Consumer:读取邮件发送主题
        mailSend-in-0:
          destination: mail_send_topic
          group: common_message-center_mail-send_tag
          content-type: application/json
        # Producer:发送主题(配合 StreamBridge 使用)
        messageOutput-out-0:
          destination: mail_send_topic
          content-type: application/json
          consumer:
            instance-count: 3
            concurrency: 5
      rocketmq:
        binder:
          name-server: ***.***.***.***:****
        bindings:
          mailSend-in-0:
            consumer:
              subscription: MESSAGE_MAIL_SEND_TAG  # TAG 过滤(Broker 侧)
              push:
                delayLevelWhenNextConsume: 0
                suspendCurrentQueueTimeMillis: 1000
          messageOutput-out-0:
            producer:
              sendType: Sync
              sendMessageTimeout: 3000
              retryTimesWhenSendFailed: 2

4.2 QQ 邮箱 SMTP 配置

QQ 推荐 465 SSL 或 587 STARTTLS。Boot 3.x 走 Jakarta Mail,常见属性如下。

spring:
  mail:
    host: smtp.qq.com
    port: 465
    protocol: smtp
    username: ***@***
    password: ******
    default-encoding: UTF-8
    properties:
      mail.smtp.auth: true
      mail.smtp.ssl.enable: true   # 465 端口建议启用
      mail.smtp.starttls.enable: false
      mail.debug: true             # 开启后可见 SMTP 交互日志

排错要点

553 Mail from must equal authorized user:发信地址必须与 username 一致,helper.setFrom() 要用同一账号。 Got bad greeting ... [EOF]:网络/端口/SSL 握手异常,确认 465 走 ssl.enable=true,或改用 587 + starttls.enable=true。


5. 生产者与消费者代码

5.1 生产者:StreamBridge 发送

@Slf4j
@Component
@AllArgsConstructor
public class MessageSendProduce {
    private static final String MESSAGE_OUTPUT_BINDING = "messageOutput-out-0";
    private final StreamBridge streamBridge;
​
    public void mailMessageSend(MailMessageSendEvent event) {
        String keys = UUID.randomUUID().toString();
        Message<MailMessageSendEvent> message = MessageBuilder
                .withPayload(event)
                .setHeader(Headers.KEYS, keys)
                .setHeader(Headers.TAGS, MessageRocketMQConstants.MESSAGE_MAIL_SEND_TAG)
                .build();
​
        long start = SystemClock.now();
        boolean result = false;
        try {
            result = streamBridge.send(MESSAGE_OUTPUT_BINDING, message);
        } finally {
            log.info("邮箱消息发送,状态: {}, Keys: {}, 耗时: {} ms, Payload: {}",
                    result, keys, SystemClock.now() - start, JSON.toJSONString(event));
        }
    }
}

5.2 函数式消费者绑定

@Slf4j
@Configuration
@RequiredArgsConstructor
public class MessageFunctions {
    private final MailMessageSendHandler handler;
​
    @Bean
    public Consumer<Message<MailMessageSendEvent>> mailSend() {
        return msg -> handler.handle(msg.getPayload(), msg.getHeaders());
    }
}

5.3 消费者 + 幂等

关键点:幂等仅解决 同一条 MQ 消息 的重复消费;拦不住“接口被重复调用而产生多条不同消息”。

@Slf4j
@Component
@RequiredArgsConstructor
public class MailMessageSendHandler {
    private final MessageSendFacade messageSendFacade;
​
    @Idempotent(
        uniqueKeyPrefix = "mail_message_send:",
        key = "#event.messageSendId",          // 建议只用稳定业务键,不要拼 hashCode
        type = IdempotentTypeEnum.SPEL,
        scene = IdempotentSceneEnum.MQ,
        keyTimeout = 600L
    )
    public void handle(MailMessageSendEvent event, Map<String, Object> headers) {
        long start = System.currentTimeMillis();
        try {
            MessageSend messageSend = BeanUtil.toBean(event, MessageSend.class);
            messageSendFacade.mailMessageSend(messageSend);
        } finally {
            log.info("Keys: {}, MsgId: {}, 耗时: {} ms, Message: {}",
                    headers.getOrDefault("rocketmq_KEYS", headers.get("KEYS")),
                    headers.getOrDefault("rocketmq_MESSAGE_ID", headers.get("MESSAGE_ID")),
                    System.currentTimeMillis() - start,
                    JSON.toJSONString(event));
        }
    }
}

6. 邮件发送实现与模板缓存

@Slf4j
@Component
@AllArgsConstructor
public class MailMessageProduceImpl implements ApplicationListener<ApplicationInitializingEvent>, MailMessageProduce {

    private final MailTemplateMapper mailTemplateMapper;
    private final JavaMailSender javaMailSender;
    private final Configuration configuration; // FreeMarker

    @SneakyThrows
    @Override
    public boolean send(MessageSend messageSend) {
        try {
            MailTemplateDO mailTemplateDO = mailTemplateMapper.selectOne(Wrappers
                .lambdaQuery(MailTemplateDO.class)
                .eq(MailTemplateDO::getTemplateId, messageSend.getTemplateId()));

            MimeMessage mimeMessage = javaMailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
            helper.setFrom(messageSend.getSender());         // 必须与 spring.mail.username 一致(QQ)
            helper.setSubject(messageSend.getTitle());
            if (StrUtil.isNotBlank(messageSend.getCc())) {
                helper.setCc(messageSend.getCc().split(","));
            }
            if (StrUtil.isNotBlank(messageSend.getReceiver())) {
                helper.setTo(messageSend.getReceiver().split(","));
            }

            Map<String, Object> model = Maps.newHashMap();
            String[] templateParams = mailTemplateDO.getTemplateParam().split(",");
            if (ArrayUtil.isNotEmpty(templateParams)) {
                for (int i = 0; i < templateParams.length; i++) {
                    // 注意防止越界
                    Object val = (messageSend.getParamList().size() > i) ? messageSend.getParamList().get(i) : "";
                    model.put(templateParams[i], val);
                }
            }

            String templateKey = messageSend.getTemplateId() + ".ftl";
            Template template = Singleton.get(templateKey, () -> {
                try { return configuration.getTemplate(templateKey); }
                catch (IOException e) { throw new RuntimeException(e); }
            });

            String html = FreeMarkerTemplateUtils.processTemplateIntoString(template, model);
            helper.setText(html, true);
            javaMailSender.send(mimeMessage);
            return true;
        } catch (Throwable ex) {
            log.error("邮件发送失败,Request: {}", JSONUtil.toJsonStr(messageSend), ex);
            return false;
        }
    }

    /** 预热模板缓存 */
    @SneakyThrows
    @Override
    public void onApplicationEvent(ApplicationInitializingEvent event) {
        Resource[] resources = new PathMatchingResourcePatternResolver()
            .getResources(ResourceUtils.CLASSPATH_URL_PREFIX + "templates/*.ftl");
        for (Resource resource : resources) {
            String templateName = resource.getFilename();
            Singleton.put(templateName, configuration.getTemplate(templateName));
        }
    }
}

7. 幂等实现原理梳理

7.1 注解定义

  • @Idempotent 支持三种 验证类型:TOKEN / PARAM / SPEL;
  • 支持两种 场景:RESTAPI 与 MQ;
  • 在 MQ+SPEL 场景下,依靠 uniqueKeyPrefix + key 形成 Redis 防重复键。

7.2 AOP 切面与模板方法

@Aspect
public final class IdempotentAspect {
    @Around("@annotation(pers.seekersferry.framework.idempotent.annotation.Idempotent)")
    public Object idempotentHandler(ProceedingJoinPoint joinPoint) throws Throwable {
        Idempotent idempotent = getIdempotent(joinPoint);
        IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type());
        try {
            instance.execute(joinPoint, idempotent);  // 模板方法:先做前置幂等处理
            return joinPoint.proceed();               // 通过则执行业务
        } catch (RepeatConsumptionException ex) {
            if (!ex.getError()) { return null; }      // 非错误态重复,直接吞掉
            throw ex;                                 // 错误态则上抛
        } finally {
            instance.postProcessing();                // 成功后把状态写为 CONSUMED
            IdempotentContext.clean();
        }
    }
}

7.3 MQ + SPEL 执行器

  • buildWrapper() 解析 SpEL Key;
  • handler() 用 Redis SETNX 写入 CONSUMING,失败判断为重复消费:
    • 如之前状态为 CONSUMED,直接跳过;
    • 如是异常导致的重复,可根据状态决定是否抛错重试;
  • postProcessing():成功后把 Key 设置为 CONSUMED 并带过期时间;
  • exceptionProcessing():异常时删除 Key(或标记为 ERROR,视业务需求)。

关键修复:不要使用 event.hashCode() 拼接 Key,反序列化后对象 hashCode() 不稳定,容易导致幂等失效。只用业务唯一键(如 messageSendId)。

7.4 这套幂等能解决什么?

  • 能解决:同一条 MQ 消息在“至少一次投递”语义下被重复消费。消费者抛错触发重试、Broker 负载/重平衡造成重复拉取、生产者重试导致 topic 中出现重复消息等。
  • 不能解决:接口被用户多次点击、重放攻击、或因业务重试导致的多次发送不同消息

因此最佳实践是:接口层 + 消费层 双重幂等


8. 接口层的幂等与频控建议

8.1 验证码发送:基于业务键的去重(推荐)

  • Key:mail:vc:{receiver} 或 mail:send:{templateId}:{receiver};
  • TTL:60s~300s;
  • 流程:接口进入先 SETNX,失败返回“发送过于频繁”。
@Idempotent(
    type = IdempotentTypeEnum.SPEL,
    scene = IdempotentSceneEnum.RESTAPI,
    key = "#cmd.templateId + ':' + #cmd.receiver",
    keyTimeout = 300
)
public CommonResult<MessageSendRespDTO> sendMailMessage(@RequestBody @Valid MailSendCommand cmd) {
    // 校验通过后再生成 messageSendId 并投递 MQ
}

或者直接用 Redis 操作封装一个 tryLockSend(templateId, receiver, ttl),语义更清晰。

8.2 数据库侧的兜底约束

  • 在 send_record 上设计唯一索引(如 uniq(template_id, receiver, date_bucket)),防止同窗口重复插入;
  • 或单独建“去重表”记录唯一键 + 过期时间,插入失败即判定重复。

8.3 限流

  • 对 receiver 或 IP 做 QPS/滑窗限流,避免外部接口超限/拉黑。

9. 典型故障与解决

症状日志

根因

处理

553 Mail from must equal authorized user

QQ 要求发件人与认证用户一致

helper.setFrom() 与 spring.mail.username 保持一致

Got bad greeting ... [EOF]

SSL 握手/网络异常/端口不匹配

465 端口启用 mail.smtp.ssl.enable=true;或改 587 + starttls

MQ repeated consumption 警告

同一消息被重复投递,拦截生效

属正常告警,可降级为 info;确认 Key 设计稳定即可

LogUtil 防空示例

public final class LogUtil {
    public static Logger getLog(@Nullable ProceedingJoinPoint joinPoint, Class<?> fallback) {
        if (joinPoint == null) return LoggerFactory.getLogger(fallback);
        Signature sig = joinPoint.getSignature();
        if (sig instanceof MethodSignature ms) {
            return LoggerFactory.getLogger(ms.getDeclaringType());
        }
        return LoggerFactory.getLogger(fallback);
    }
}

调用处传入 this.getClass() 作为兜底。


10. 最终效果验证(节选)

  • 接口成功,生产者发送 OK;
  • 消费者首投递成功发送邮件并落库:status=0(SUCCESS);
  • 重复投递时出现:
[mail_message_send:********] MQ repeated consumption
  • 直接跳过,不再发邮件、不再落库。

11. 总结与最佳实践清单

  1. 版本选型:Northfields + SCA 2023.0.3.3,RocketMQ Client 5.3.x,函数式编程模型配合 StreamBridge 最稳。
  2. 邮箱发送:QQ 465 端口启用 ssl.enable=true,from 必须等于 username;打开 mail.debug 便于排障。
  3. 模板参数:DB template_param 与 paramList 数量要匹配,代码里务必做越界保护。
  4. MQ 幂等 Key:只用稳定的业务键(如 messageSendId),不要使用 hashCode()。
  5. 双层幂等
  6. 接口层:业务键/Redis SETNX + 合理 TTL;必要时叠加唯一索引或去重表;可做频控。
  7. 消费层:MQ + SPEL 幂等,首次 SETNX,成功后写 CONSUMED,重复投递直接忽略。
  8. 日志级别与可观测性:重复消费属常见场景,建议告警降噪;关键链路打印 Keys、MsgId、耗时、入参摘要,方便对账与排错。

12. 附录:验证码模板示例(FreeMarker)

resources/templates/userRegisterVerification.ftl

<html lang="zh-CN">
<body>
<div>
  <p>亲爱的用户:</p>
  <p>您好!本次验证码为:<b style="font-size: 32px; color:#2D7BFF;">${validCode!""}</b></p>
  <p>为保障您的账户安全,请在 5 分钟内完成验证,验证码将自动失效。</p>
</div>
</body>
</html>

完。 如需把接口层幂等与频控抽成公共组件,或加上统一的“发送频率策略(按邮箱/按模板/按 IP)”,可以在此文的基础上继续演进。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值