租户 A 的验证码,却用了租户 B 的 AccessKey:Forge Admin 修跨租户短信串发

租户 A 的用户点「获取验证码」,短信签名却是租户 B 的;运维去云厂商控制台一对,扣费账单写在别人的 AccessKey 上。更糟的一幕:发版重启后,所有租户的登录验证码和通知短信一起哑火------配置页看着都启用着,通道却一条都没注册上。值班群第一反应往往是:「短信厂商又限流了?」或者「是不是租户过滤器没开?」

Forge Admin(开源后台,仓库见文末)在 2026-10-31(UTC,用户时区 Asia/Shanghai 为当天上午)的提交 20ed8b3dc(合并 6d28a422c)里,把这两类故障钉成同一条修复:空 maximum 拆箱 NPE 会吞掉整表 Blend 注册;选路未命中时禁止回退到进程级 SmsFactory.getSmsBlend(),并把运行时键改成「租户ID:业务configId」。 下面按仓库真实 diff 与单测写;未起实例库做短信实发处标明「按源码推演」,发布前请作者自行验证。

查重:和已发的 Boot4 / flw_* 纯净库 / 门户入口权限 / JAR Flyway / 公共节点合并 / 超时终态 / designPreview / FlowStart / 在线 DDL / snake-camel / 分页 COUNT / RBAC·DataScope / 版本公告都不是同一题。本篇只谈短信通道注册失败与跨租户串发。

TL;DR

  1. 现象 A:重启后登录验证码、通知短信全部发不出,通道配置仍显示启用。
  2. 现象 B:指定通道找不到时,短信走了别的通道甚至别的租户的密钥;监控里成功却记在错误的 configId 上(按源码推演)。
  3. 根因 A :ReadConfig.buildBaseConfig 对可空 Integer maximum 直接拆箱 → NPE;getSupplierConfigList 捕获后返回空列表,sms4j 一条 Blend 都注册不上。
  4. 根因 B :SmsMessageChannel.resolveBlend 在按 configId 取不到时回退无参 SmsFactory.getSmsBlend()(进程级负载均衡);业务 configId 由用户填写,跨租户会重名。
  5. 修复 :空上限不拆箱;未知供应商跳过;Blend 键=tenantId:configId;未命中只 ensure 补注册,失败走 failover;定时 Ping 切回对应租户上下文。
  6. 自查 :先看进程内 Blend 键有没有租户前缀,再查有没有无参 getSmsBlend() 回退,最后看 Ping 是否 executeWithTenant。

全景:一条短信从选路到厂商

短信多通道能力来自前置提交 c03e94150(并存、权重轮询、可选 Ping)。请求大致走这条链:

text 复制代码
业务发送 / 登录验证码
  → SmsMessageChannel.send
  → SmsRouter 按场景与权重给出 attemptOrder
  → 对每个 SmsConfig:resolveBlend → sms4j SmsBlend 发厂商
  → 失败则 failover 下一通道

启动 / 刷新
  → ReadConfig.getSupplierConfigList → sms4j 注册进程级 Blend 表
  → 键曾经是业务 configId;修复后是「租户ID:configId」

定时探活
  → SmsPingScheduler 遍历租户 → runPingNow

两个坑分别卡在「整表注册」和「单次选路回退」------一个让你发不出 ,一个让你发出去但用错账号。

坑一:maximum 为空,整表 Blend 注册被 NPE 吞掉

现象

CHANGELOG 与合并说明写得很直白:启用通道的 maximum(发送上限)为空时,整表 Blend 注册失败;登录验证码和通知短信在重启后全部发不出去。新配置页已不再采集该字段,库列默认也是 NULL------所以这不是「谁手贱填错」,而是升级/迁移后的常态数据。

根因(代码)

修复前 ReadConfig.buildBaseConfig(20ed8b3dc 的父版本):

java 复制代码
baseConfig.setMaximum(config.getMaximum());

BaseConfig#setMaximum 侧若是 int 形参,Integer 为 null 时自动拆箱就会 NPE。而 getSupplierConfigList 用 try/catch 包住整段映射:

java 复制代码
return configs.stream().map(this::buildBaseConfig).toList();
} catch (Exception e) {
    log.warn("自动加载短信配置失败: {}", e.getMessage());
}
return List.of();

按源码推演:任意一条启用配置在 buildBaseConfig 里炸一次,stream 整段失败 → 捕获后返回空列表 → sms4j 启动时一条 Blend 都注册不上。于是业务侧看到的是「通道未初始化 / 发送失败」,而不是「某一行 maximum 为空」。

另一个连带点:createConfigBySupplier 对未知供应商返回 null,旧代码仍继续 baseConfig.setAccessKeyId(...),同样会 NPE 并拖垮整表(合并说明与修复后的 skip 逻辑对应)。

怎么改

20ed8b3dc 做了三处收口:

  1. maximum == null 时跳过 setMaximum;
  2. 未知供应商打 warn 并 return null,单条跳过;
  3. getSupplierConfigList 改为逐条 buildBaseConfig,只收集非 null,不再一条坏配置清空整表。
java 复制代码
if (baseConfig == null) {
    log.warn("跳过无法识别的短信供应商: configId={}, supplier={}",
            config.getConfigId(), config.getSupplier());
    return null;
}
// ...
if (config.getMaximum() != null) {
    baseConfig.setMaximum(config.getMaximum());
}

单测 ReadConfigBlendTest 覆盖「只注册本租户运行时键、不会把别的租户前缀混进来」等行为(仓库内 forge-starter-message 测试目录)。

坑二:找不到通道就「负载均衡」到别人的密钥

现象

合并说明(6d28a422c)描述的第二类正确性问题:

指定通道在 SmsFactory 中找不到时,回退到无参 SmsFactory.getSmsBlend()。该重载是进程级负载均衡,会改走其它通道甚至其它租户的 AccessKey,并把成功记到错误的 configId 上。业务 configId 由用户填写,跨租户会重名。

值班画面(按源码推演,未做生产复现):租户 A、租户 B 都配置了 configId=alibaba;A 的 Blend 因刷新时序暂未命中,发送却「成功」了------账单与签名落在 B。监控日志里仍打印 A 的业务 configId,排障会以为「配置没错」。

根因(代码)

修复前 SmsMessageChannel.resolveBlend:

java 复制代码
private SmsBlend resolveBlend(String configId) {
    if (StrUtil.isNotBlank(configId)) {
        SmsBlend blend = SmsFactory.getSmsBlend(configId);
        if (blend != null) {
            return blend;
        }
    }
    return SmsFactory.getSmsBlend(); // 无参:进程级负载均衡
}

两层叠加:

  1. 回退语义错误:未命中应视为本通道不可用并走 failover,而不是从进程里随便挑一个活着的 Blend。
  2. 键空间未带租户 :sms4j 的 Blend 表是进程级 Map,键为 configId;用户填写的业务标识跨租户天然会撞车(测试直接断言 1:alibaba ≠ 2:alibaba)。

同时,旧版 SmsConfigProviderImpl.toProviderConfig 没有 把 entity.getTenantId() 拷进运行时 SmsConfig,即使想做租户前缀也缺材料。

怎么改

  1. 新增 SmsBlendIds.runtimeId(tenantId, configId) → "租户ID:trim(configId)"(tenant 空则 0)。
  2. ReadConfig 注册与匹配都使用运行时键;matchesRequestedId 同时兼容裸业务 id 与带前缀 id。
  3. SmsConfig 增加 tenantId,Provider 映射时写入。
  4. 新类 SmsBlendResolver:只按 runtimeId 查找 ;没有则 SmsBlendRefresher.ensure(runtimeId) 补注册再取;仍没有返回 null,禁止 落到无参 getSmsBlend()。
  5. SmsMessageChannel 改为 resolveBlend(config),用 SmsBlendIds.runtimeId(config.getTenantId(), config.getConfigId())。

单测 SmsBlendResolverTest.missingBlendDoesNotFallBackToAnotherChannel 明确:查找器里即使放着「别的租户」的 mock Blend,未命中目标 runtimeId 时也不得返回它;ensure 后才拿到本通道。

坑三:定时 Ping 没切租户上下文(顺带修)

SmsPingScheduler 在 @IgnoreTenant 下遍历租户 ID 做探活。修复前直接 smsConfigService.runPingNow(tenantId);注释写明风险:

发送链路按当前租户取通道;不切换上下文会落到默认租户,探活打到别人的账号上

修复:

java 复制代码
int success = TenantContextHolder.executeWithTenant(tenantId,
        () -> smsConfigService.runPingNow(tenantId));

按源码推演:若 list 通道仍依赖租户上下文/数据权限,不切换时 Ping 成功数、失败日志都会「看起来像默认租户的通道在抖」。

选路权重键:别把所有租户挤进 wrr:0

同提交里 SmsRouter 增加 routeTenantId:请求未显式传租户 hint 时,用通道自身的 tenantId,避免加权序列全部挤进 forge:sms:wrr:0。SmsRouterTenantTest 断言:通道租户为 9 时,序列回调看到的是 9 而不是缺省 0。这和串发不是同一条因果链,但是多租户下「选路状态串味」的同一类病。

修复前后对照(宜收藏)

点 修复前 修复后(20ed8b3dc)
maximum 为空 setMaximum 拆箱 NPE → 整表注册空 跳过 set;单条失败不拖垮列表
未知 supplier baseConfig==null 继续设字段 → NPE warn + skip
Blend 键 业务 configId(跨租户可重名) tenantId:configId
取不到 Blend SmsFactory.getSmsBlend() 无参负载均衡 ensure 后仍无则 null,走 failover
Provider 未带 tenantId toProviderConfig 写入
定时 Ping 未切租户上下文 executeWithTenant
权重序列租户 hint 空时易落到 0 回退到通道自身 tenantId

上线自查清单(宜收藏)

  • 库表启用通道的 maximum 是否大量为 NULL?新配置页是否已不采集该字段?
  • 启动日志是否出现「自动加载短信配置成功: count=N」且 N>0,而不是「自动加载短信配置失败」后 count 变 0?
  • 进程内 / 调试输出的 Blend 键是否为 1:alibaba 这种带租户前缀,而不是裸 alibaba?
  • 代码检索是否还存在业务发送路径调用无参 SmsFactory.getSmsBlend()?
  • 两个租户故意配置相同业务 configId,分别发测试短信,签名与云厂商 AccessKey 是否各自隔离?(实发验证)
  • 定时 Ping 日志的 tenantId= 与实际命中通道是否一致?是否在对应租户上下文执行?
  • 单测:mvn -pl forge-framework/forge-starter-parent/forge-starter-message -am test -Penable-tests -Dtest=SmsBlendIdsTest,SmsBlendResolverTest,SmsRouterTenantTest,ReadConfigBlendTest(合并说明:6 个用例通过;未起实例库做短信实发)。

和「消息中心加固」的边界

7662aec15 加固的是收件箱详情 IDOR、发送/监控鉴权、渠道密钥脱敏等------读接口与密钥展示 问题。本篇的串发是运行时选路与进程级 Blend 表键空间问题。两者都在消息域,但排障入口不同:串发先查 Blend 键与无参回退,不要先改成「再加一层权限注解」就结束。

写在最后

多租户里最危险的不是「过滤器忘了加 tenant_id」,而是进程级单例表用了业务可读的短名字当键,再在 miss 时友好回退到「随便一个能用的」。短信这种带真金白银 AccessKey 的通道,友好回退等于友好串账。

如果这篇文章帮你在自己的 sms4j / 自研通道里少踩一个坑,麻烦点个赞和收藏------下次排障能搜到。也欢迎在评论区出下一题:你更想看消息中心监控鉴权、还是验证码防刷与通道 Ping 策略?

仓库(开源):gitee.com/ForgeLab/fo...


作者发布前请验证(按源码推演点)

  1. 生产/预发是否真实出现过「签名串租户」账单,还是仅由无参回退 + 同名 configId 推演得出?
  2. BaseConfig#setMaximum 在当前 sms4j 版本中形参是否确为 int(拆箱路径);若已是 Integer,则 NPE 主因可能更多来自 baseConfig == null 后继续 set 字段。
  3. Ping 路径 runPingNow 内部是否完整依赖 TenantContextHolder(而非只吃入参 tenantId)。
  4. 合并说明写明未做实例库短信实发;发布前建议按自查清单做一次双租户实发对照。
相关推荐
LabVIEW开发1 小时前
LabVIEW 局部变量与数据流:这道 CLAD 题为什么答案是 Undefined
java·开发语言·labview
文丑颜不良啊1 小时前
LeetCode1721~1740题目解析
java·leetcode
wangbing11251 小时前
监控网页项目常用英文缩写
java
麻瓜code1 小时前
双写一致性:为什么是“删缓存“,而不是“改缓存“
java·缓存·mybatis
小蒜学长1 小时前
基于SpringBoot+Vue的云南地区扶贫助农系统的设计与实现(代码+数据库+LW)
java·vue.js·spring boot·后端·乡村振兴
RuoyiOffice1 小时前
Hyper-V+Kubernetes 搭建实战:保留 Windows,构建三节点 Linux 容器集群
linux·spring boot·kubernetes·containerd·hyper-v·微服务部署·ruoyi office
砚底藏山河1 小时前
量化回测可复现流水线CI化
java·python·ci/cd·金融
骑着蜗牛撵大象3271 小时前
Spring Boot 4.1 + Spring AI 2.0:RAG 供文、图谱供关系、MCP 供工具的 Java Agent 平台
java·spring boot·spring·微服务·知识图谱·rag·mcp
衍生星球1 小时前
【Java 语言】— 面向对象:构造方法(无参、有参构造)
java·开发语言