Skip to Content
文档特性消息通知概览

Message Starter

为 Softa 应用提供统一消息能力:

  • 邮件:发送邮件、接收邮件、渲染邮件模板
  • 短信:发送短信、批量发送、模板渲染、失败重试
  • 收件箱:向用户推送应用内通知

投递可靠性基于事务性 outbox + 乐观锁 CAS 状态机,因此 broker 故障、重复投递和进行中崩溃可在不丢消息、不重复发送的情况下处理。

依赖

<dependency> <groupId>io.softa</groupId> <artifactId>message-starter</artifactId> <version>${softa.version}</version> </dependency>

应用 API

MessageService 是暴露给业务模块的唯一消息提交服务:

渠道单条批量
MailsendMail(SendMailDTO)sendMailBatch(List<SendMailDTO>)
SMSsendSms(SendSmsDTO)sendSmsBatch(List<SendSmsDTO>)
InboxsendInbox(SendInboxDTO)sendInboxBatch(List<SendInboxDTO>)

一个 DTO 始终代表一条独立消息。批量方法接受 1..500 项,加入调用方事务,并按输入顺序返回记录 ID。一个邮件 DTO 可在一个 MIME 消息中寻址多个 to 收件人;一个 SMS DTO 始终只含一个电话号码。

要求与配置

在 src/main/resources/sql/ 下应用以下 DDL:

  • message-starter.sql — 邮件 + 收件箱表
  • message-starter-sms.sql — SMS 表
  • message-starter-outbox.sql — 事务性 outbox(邮件 + SMS 共享)
  • message-starter-dlq.sql — 统一死信存储(dead_letter_message)

使用框架 ORM/versionLock 路径进行运行时写入,因此 outbox 发布不依赖数据库特定的行锁 SQL。

硬依赖

message-starter 刻意将 Redis 和关系数据库视为硬依赖。对它们没有 fail-open / 本地回退路径——若任一不可用,依赖它的操作会向调用方抛出异常。这使运行时保持简单,并与 Softa 栈其余部分(缓存、分布式锁、会话等)的行为一致。权衡与运维期望:

依赖使用者失败行为运维期望
Database所有路径(记录、outbox、框架 versionLock)操作抛出;调用方看到 5xxHA 数据库(复制 MySQL / 托管 PG);已应用迁移。
RedisRateLimiter(每配置的投递速率窗口)、MailConfigCache操作抛出;调用方看到 5xxSentinel 或 Cluster 部署。K8s readinessProbe 应包含 /actuator/health/redis,以便 Redis 不可达时负载均衡器分流。
Pulsar brokerOutboxPublisher(发布)、消费者(订阅)Outbox 行保持 NEW;发布者指数退避重试;最终在 MAX_PUBLISH_ATTEMPTS=10 后标记 DEAD。HA 集群。失败不阻塞业务写入——outbox 吸收间隙。
SMTP / SMS provider外发发送每条记录失败;由 ErrorClassifier 分类;指数退避重试(ExponentialBackoffPolicy)。将提供商侧速率限制配置在提供商配额以下。

为何 Redis 无进程内回退? 本地 Guava 限流器会在 Redis 故障期间静默让单节点突破跨节点配额,足够长的故障可能击穿提供商日配额并产生真实成本(Twilio / 阿里云 / SES)。通过 readiness 探针在负载均衡器层面 fail closed,比部分故障下静默扇出更安全。

多租户

所有消息业务表(mail_*、sms_*、inbox_notification)为 multiTenant 模型:当平台 system.enable-multi-tenancy 开启时,读取隔离于调用方租户,写入由 ORM 自动盖章。tenant_id = -1 行(BaseConstant.PLATFORM_TENANT_ID)构成平台层——归平台运营方所有,对租户不可见。两个层级是完全分离的命名空间,不存在叠加:

  • 模板按租户种入,不共享。 每个租户在开通时由应用的租户种子文件获得自己的模板行(应用 seed-manifest.yml 里的租户文件,由 seed-starter 在租户开通时运行)。此后租户自由拥有并编辑自己的行。每个租户只跑一次同一个种子文件,所以新模板以新的租户文件的形式,由平台管理员运行后到达存量租户;修改存量租户已有的模板走数据迁移(种子文件也同步修改,供之后开通的租户使用)。平台层只保存 PLATFORM 作用域发送要渲染的模板(账单、安全);两层都需要的 code(如公开密码重置兜底)在两份种子里各出现一次。发送时的解析层级纯净:scope = TENANT 只读当前作用域自己的行,scope = PLATFORM 只读平台层。
  • 服务器/提供商配置对租户不可见。 租户只能看到并 pin 自己的配置;平台层只通过调度器的静默兜底(@CrossTenant)在租户没有配置时被触达——邮件收发兜底到平台默认,短信路由按国家兜底(同一国家内两层永不交错)。
  • 后台任务为跨租户扫描,在每条记录所属租户上下文中执行:定时邮件拉取在每个接收配置的租户内运行,僵尸清扫器在每个卡住记录的租户内恢复。
  • 事务性 outbox 和死信存储为共享基础设施表;租户身份在消息 payload(recordId / tenantId / traceId)中传递,由消费者恢复。

模型还有两个配套件:

  • 每次发送的层级策略:SendMailDTO.scope / SendSmsDTO.scope / MailRequestMessage.scope(默认 MessageScope.TENANT;PLATFORM = 模板、服务器与配额桶全部走平台层——用于账单/安全/合规消息)。MailRequestMessage.tenantId 让 MQ 消费者恢复租户上下文,TENANT 作用域的渲染因此能命中租户自己的模板,发送记录也落入租户名下。
  • 平台行在租户作用域内结构性不可写(payload 校验、插入盖章、更新/删除的租户过滤预读);邮件写端点还会把这种静默 no-op 转为带解释的 BusinessException。

多租户禁用时,无过滤或盖章,一切表现为单租户。

启用 / 禁用(框架 active 控制)

七个配置/模板模型(MailTemplate、MailSendServerConfig、MailReceiveServerConfig、SmsTemplate、SmsProviderConfig、SmsProviderRegion、SmsTemplateProviderBinding)声明 @Model(activeControl = true),使用框架的 active 字段——不再有手写的 isEnabled 开关,服务层也不再手写 active = true 条件:WhereBuilder 会把它追加到每一次 FlexQuery 读取上,因此解析、调度与列表界面的过滤保持一致,新写的查询也不可能漏掉它。禁用会让该行从所有读取中退场但不删除——这正是被发送记录引用的行所需要的。因此禁用行会离开默认列表视图——七个管理页面都做成了两个页签的 MultiView:Active(框架默认)与 All(active IN (true, false),因为点名了该字段从而抑制自动追加);没有第二个页签,禁用一行会让它从自己的管理页面消失。列头对 active 的筛选同理,可用于临时查询。必须触达禁用行的读取使用 FilterControl.bypassActiveControl():按 id 的重放(findVisibleById,因此禁用配置绝不会把在途重试变成 CONFIG_NOT_RESOLVABLE)、编辑器工具(resolveAny——模板启用前即可预览与提取变量)、平台行写入探针,以及默认项降级。普通 getById 无需绕过:按 id 查找从不携带该过滤。

月度发送配额

TenantMessageQuota(刻意非 multiTenant——平台拥有的、关于租户的登记表,只能从平台作用域写入)为每个租户设置月度邮件/短信接受上限。执行点在接受时(MonthlyQuotaGuard):超额发送同步拒绝——这是商业上限而非速率控制——投递重试不再触碰计数。计数桶跟随发送的 scope:PLATFORM 发送消耗平台自己的 tenantId = -1 行(设一个非常大的值即可,它的存在是为了兜住失控或恶意的批量发送);缺行时回退到 softa.message.quota.mail-monthly-default / sms-monthly-default(null = 不限——账本仍会累加以便报表)。计数落在数据库:每桶每日历月一行 TenantMessageUsage,通过 ORM 乐观锁 CAS(versionLock,每次尝试独立 REQUIRES_NEW 事务)做检查并自增。没有重置任务——新月份自然开新行——行也永不过期,因此每租户的历史消耗就是一次普通模型查询,且每行快照了当时生效的上限。GET /TenantMessageQuota/usage?tenantId= 返回某个桶的用量与当前解析上限(租户会话只能查自己的桶)。每配置的 dailySendLimit / rateLimitPerMinute 属于另一条轴(投递时的基础设施保护),保持不变。

异步投递(唯一投递模型)

无论 broker 配置如何,每次 MessageService.sendMail(...) / sendSms(...) 调用遵循相同路径:

  1. MailSendRecord / SmsSendRecord 和 OutboxEntry 在一个 DB 事务中写入(status = PENDING,version = 0)。方法立即返回记录 id——调用方不阻塞于 SMTP/SMS 往返。
  2. 定时 OutboxPublisher(500 ms 轮询)通过框架 versionLock 将 NEW 行认领为 PUBLISHING,发布到对应 topic,并将 outbox 行翻转为 PUBLISHED。
  3. @PulsarListener 消费者读取消息(仅携带 recordId / tenantId / traceId),然后驱动渠道的 DeliveryProcessor,在调用提供商前 CAS 转换 PENDING|RETRY → SENDING。

若未配置 broker topic,outbox 行保持 NEW 状态,发布者每次轮询重试——无丢失;发送仅排队直到运维提供 topic。当到期行等待在不可用路由上时,发布者会打出节流 WARN 点名滞留路由(每 5 分钟至多一条),因此 broker 宕机不会让记录在零日志证据下停在 PENDING。不使用 Spring @Async。

手动重试

自动恢复覆盖投递失败(SendFailureHandler:指数退避,然后 FAILED / DEAD_LETTER;认证错误、配置无法解析等不可重试失败跳过退避,首败即入死信)与在途卡死(ZombieRecordSweeper:陈旧 SENDING → RETRY、陈旧 outbox PUBLISHING → NEW)——但覆盖不了 outbox 行已对着坏 broker 死掉的记录。两个运维端点补上这个缺口:

  • POST /MailSendRecord/retry?id= / POST /SmsSendRecord/retry?id=——重新入队一条记录:PENDING / RETRY / FAILED / DEAD_LETTER → RETRY 并原子性写入一条全新 outbox 行。SENT 与在途 SENDING 被拒绝。可安全重复调用——投递认领是 CAS 门控的,重复调用空转,不可能重复发信。retryCount 持续累计:手动重试给予一次新尝试;再次失败回到 FAILED / DEAD_LETTER,而非重新武装整个自动重试预算。
  • POST /OutboxEntry/requeue?id=——重开一条 DEAD outbox 行(回 NEW,attempts 清零,立即到期)。发布失败是基础设施故障,broker 修好后应重新享有完整预算。其他状态被拒绝。

HCM 管理界面在 Mail Send Record / SMS Send Record / Outbox Entry 详情页以按状态显隐的工具栏动作暴露这两个操作,并在对应列表页提供行级动作。

Broker topics

仅需声明实际使用的渠道 topic。初始投递与延迟重试共享同一 topic;重试时机由事务性 outbox 的 next_attempt_at 携带,而非编码为独立 broker 路由。发送死信为终态记录状态(DEAD_LETTER)加上归档到统一 dead_letter_message 存储的行(见下文死信存储),而非独立队列。

mq: topics: mail-send: topic: dev_demo_mail_send sub: dev_demo_mail_send_sub sms-send: topic: dev_demo_sms_send sub: dev_demo_sms_send_sub cron-task: topic: dev_demo_cron_task mail-fetch-sub: dev_demo_cron_task_mail_fetch_sub

Message-starter 属性

绑定前缀 softa.message,来源为 MessageProperties、RetryProperties 与 DLQ 的 @Value 键:

softa: message: outbox: enabled: true # default true; disable on read-only replicas poll-interval-ms: 500 zombie: enabled: true # default true stale-seconds: 300 # stale SENDING/PUBLISHING claims are revived cron: "0 * * * * *" # every minute retry: default-max-attempts: 5 exponential: base-seconds: 30 max-seconds: 3600 multiplier: 2.0 jitter: 0.5 # ±50% randomisation quota-floor-seconds: 300 # QUOTA errors wait at least 5 min dlq: topic: dev_demo_message_dlq # unset = broker-poison archiving disabled max-redeliver: 5 # broker nacks before dead-lettering alert: recipients: [email protected] # comma-separated; empty = no alert mail mail: debug: false # Jakarta Mail protocol debug — never enable in prod (leaks AUTH) max-body-chars: 1048576 # 出站正文单字段受理上限(字符数);列本身是无界 TEXT fetch: batch-limit: 100 # max messages per cron tick per (config, folder) lease-timeout: 1h # abandoned IMAP watermark lease takeover max-message-size: 100MB # RFC822 size cap; oversize → envelope-only + BodyTooLarge max-attachment-size: 20MB # per-part cap; oversize parts skipped archive-eml: false # opt-in raw EML archive via FileService max-mime-depth: 10 # MIME zip-bomb guard max-mime-parts: 100 # attachment-storm guard transport: connection-timeout: 5s # SMTP/IMAP/POP3 connect timeout read-timeout: 30s # SMTP/IMAP/POP3 read timeout sms: transport: connection-timeout: 5s # HTTPS RestClient connect timeout read-timeout: 30s # HTTPS RestClient read timeout

邮件认证

邮件服务器使用用户名 + 密码认证。提供商将 API key 作为 SMTP/IMAP 凭证时,将该 key 作为 password 提供。常见设置:

  • ESP / SMTP 中继,API key 凭证(SendGrid、Amazon SES、Postmark、Mailgun):在 mail_send_server_config 上将 key 设为 password;
  • 提供商应用密码,账户签发时;
  • 自托管 MTA(如 Stalwart、Postfix)。

运维

指标

当 classpath 上有 Micrometer 时,MessageMetrics 发出四类计数器:

名称标签递增时机
softa.message.sentchannel(mail/sms)、provider提供商调用成功
softa.message.failedchannel、provider、outcome(retry/failed/dead_letter)提供商调用失败
softa.message.outbox.publishedrouteOutbox 条目成功发布到 broker
softa.message.outbox.deadrouteOutbox 条目超过发布尝试 → DEAD

softa.message.failed{outcome=dead_letter} 在发送记录转换为 DEAD_LETTER 时由 SendFailureHandler 发出。

入站投递状态

邮件退信和已读回执状态来自 IMAP 接收路径上的入站邮件——DSN 报告邮件(DsnRule / MailerDaemonRule → BounceReceiptLinker → markBounced)和 MDN 邮件(ReadReceiptRule)。不提供入站 HTTP 回调;对于提供商推送的投递事件(SMS DLR、ESP 邮件事件),在应用中增加控制器调用记录服务的 CAS 转换。

速率限制

MailSendServerConfig 和 SmsProviderConfig 携带两个配额列:

  • daily_send_limit — 每日累计发送
  • rate_limit_per_minute — 每分钟发送(平滑突发)

任一可为 null/zero 以禁用该窗口。计数器位于 Redis(rl:{channel}:{daily|min}:{tenantId}:{configId}:{window}),因此多实例部署共享一个预算。配额突破表现为提供商侧 QUOTA_EXCEEDED 错误,分类为 ErrorCategory.QUOTA——重试策略应用配置的 quotaFloorSeconds(默认 5 分钟),避免猛击提供商。

僵尸记录清扫器

ZombieRecordSweeper 每分钟运行。updated_time 早于 softa.message.zombie.stale-seconds(默认 300)且卡在 SENDING 的记录,经 versionLock 转换回 RETRY,next_retry_at = now,并在同一事务中写入重试 outbox 行。过期的 outbox PUBLISHING 认领重新打开为 NEW。覆盖 JVM 在认领记录与完成提供商/broker 调用之间崩溃的情况。

在只读副本上通过 softa.message.zombie.enabled=false 禁用。

敏感字段加密

配置表上的凭证列(mail_*_server_config.password、sms_provider_config.api_secret)定义得足够宽以容纳密文。框架透明加密(MetaField.isEncrypted())处理读写侧——但部署时仍须在 SysField 元数据表中将这些字段标记为 encrypted。见 字段元数据 encrypted 属性;开箱即列存储明文。

扩展点

邮件传输

邮件发送仅 SMTP。MailSendServerConfig 是完整外发服务器配置;SmtpMailTransport 无状态,每次发送构建新的 Jakarta Mail sender,因此配置变更仅需驱逐 Redis 配置缓存(update/delete 时自动)。

邮件分类规则

MailClassifier 是对 MailClassificationRule bean 的责任链。四个内置规则(已读回执 → DSN → mailer-daemon → 关键词)按 @Order 顺序运行;第一个返回分类的规则获胜。添加提供商特定检测——如旧版 Exchange NDR、中国 ISP 退信形态——使用新规则:

@Component @Order(25) // between DsnRule (20) and MailerDaemonRule (30) public class ExchangeNdrRule implements MailClassificationRule { @Override public Optional<MailClassification> match(MimeMessage message) throws Exception { // ... return MailClassification.bounce(info) if it matches return Optional.empty(); } }

死信存储

两层的死信汇聚到单一 dead_letter_message 表以便分拣,由 source 列区分:

  • SendExhausted — 邮件 / SMS 发送记录耗尽提供商重试预算;由 SendFailureHandler 归档(记录 id 在 event_id,失败详情在 JSON payload)。
  • BrokerPoison — Pulsar 消费者在 broker 最大 redelivery 后仍无法处理消息;原始信封路由到 DLQ topic 并由 DeadLetterConsumer 归档。用 @PulsarListener(deadLetterPolicy = "commonDlqPolicy") 选择监听器并设置 softa.message.dlq.topic。

通过 DeadLetterMessageController 分拣行(状态 Pending → Resolved / Discarded)。自定义告警(Slack、PagerDuty)可消费 DLQ topic 或监视表——无进程内监听器 SPI。

重试策略

失败发送由 ExponentialBackoffPolicy 重试——可配置基数、乘数、上限和 ±jitter,通过 softa.message.retry.exponential.* 调优。ErrorClassifier 的错误类别决定处置:TRANSIENT / QUOTA / UNKNOWN 重试(QUOTA 钳制到 quota-floor-seconds)直至达到 default-max-attempts 并死信;PERMANENT / INVALID_INPUT / AUTH 立即失败不重试。RetryDecision 为密封类型(Retry / Fail / DeadLetter),使失败处理器的 switch 保持穷尽。

最后更新于