news 2026/10/3 3:12:11

Spring Boot短信接入实战:从平台选型到容灾设计的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot短信接入实战:从平台选型到容灾设计的完整指南

做后端开发的,几乎都会碰到短信这个需求——用户注册要发验证码,登录二次校验要发验证码,订单状态变更要发通知,营销活动想推送短信,短信接口本身不算复杂,本质就是调一个HTTP/SDK接口,把手机号和内容传过去让平台下发。但我在实际项目和代码评审里见过太多把短信写死的场景:直接在Service里贴一段阿里云或腾讯云的SDK调用,密钥硬编码在代码里,换一个短信平台要翻十几个文件,短信发送没有日志记录,线上出了问题根本分不清是平台拒发、参数拼错还是压根没调。这篇文章把Spring Boot项目接入短信平台的完整链路拆一遍,从方案选型、配置管理、代码结构到签名模板审核的坑,理出一条比较优雅的接入路径。适合刚开始做短信接入的Java开发,也适合想把现有短信代码重构得干净一点的同学。

1. 接入前必须想清楚:短信平台选型与接口形态

1.1 平台选型不是看谁便宜,要看这几个硬指标

做技术选型时很多人上来就问价格,其实价格只是最后一环。我接过的短信需求里,真正影响后续开发体验的是这几个因素。

第一是签名和模板的审核效率。国内正规短信平台都要求先申请签名(比如“某某科技”)和模板(比如“您的验证码为${code},5分钟内有效”),签名的审核周期从半小时到两三天不等。个人开发者申请签名时一般只让带“验证码”、“通知”这类字眼,企业签名则需要营业执照等资质。这个环节如果平台审核慢,项目进度就会被卡住。所以选平台前先看看拿到一个可用的签名和模板需要多久,不要等代码写完了才发现签名还没批下来。

第二是到达率和稳定性。短信到达率受平台通道质量影响很大,同样的内容走不同平台,到达率可能差好几个百分点。这个没法从官网页面看出来,最好做一周的小流量实测,拿一批测试手机号跑一遍,关注到达耗时和失败率。线上用的通道一定要有备选,这一点我后面会专门说。

第三是接口文档的完善程度。大厂的短信平台文档做得相对细致,错误码、限流策略、签名算法都写得比较清楚。有些中小型平台或企业内部自建的短信网关,只给你一份HTTP接口文档,连SDK都没有,这种就要自己封装底层调用。标题里提到的云MAS平台HTTP接口就是典型的自建网关类型,接入前要确认鉴权方式、报文格式、错误码含义这些信息是不是齐全。

第四才是价格。短信行业的定价基本透明,单条从几分钱到一毛多不等,量大可以谈,但别因为贪便宜选一个通道质量没保证的小平台,验证码短信到达率掉几个点,用户流失的成本远比省下的短信费高。

1.2 大厂SDK和原生HTTP接口怎么选

确定了平台之后,接口形态又是一个选择点。阿里云、腾讯云、华为云这些主流平台一般提供官方SDK,封装了签名、请求、重试等逻辑,开发效率高。但SDK也有SDK的问题:版本升级频繁,依赖冲突时有发生;有些SDK体积不小,只是发条短信就要引入一堆传递依赖;官方SDK的抽象风格各不相同,今天接阿里云用一套API,明天接腾讯云又是另一套,业务代码里全是平台相关的API调用,很难维护。

我个人的习惯是:不管平台有没有SDK,业务代码里都不直接依赖SDK的API,而是基于一个自定义的抽象接口来发短信。这样做的好处非常明显:换平台时只改底层实现类,业务代码一行不用动;多个平台共存时,可以做主备切换或按手机号段路由;测试时也方便用Mock替换真实的发送逻辑。

如果平台只提供HTTP接口文档而没有SDK,那就用RestTemplate或OkHttp封装一个HTTP客户端。需要注意的是HTTP接口的签名机制,常见的做法是把AppKey、时间戳、随机数做HMAC或MD5签名,具体算法以平台文档为准。封装的时候把签名计算单独抽一个方法,日志里不要打印完整签名值和密钥。

1.3 整体架构:一条主线,三层设计

接入短信这件事,正确的打开方式不是写一个“短信工具类”,而是把它当成一个完整的子系统来设计。我的习惯是分成三层:

  • 接入层:Controller接口或MQ消费者,接收发送短信的请求,做基础校验。
  • 业务层:负责拼装模板参数、校验手机号、频控、组织发送请求,调用抽象层。
  • 能力层:抽象接口 + 各平台实现类,真正干活的SDK调用或HTTP请求都在这一层。

为什么要分层?因为短信发送往往分布在多个业务模块里——用户服务发验证码,订单服务发通知,营销服务发活动短信。如果每个模块都自己拼参数调SDK,你会得到一堆重复代码,而且每个模块对短信平台的理解还不一定一致,出问题的时候排查成本极高。统一走一个短信服务,所有发送行为都有统一的入口、统一的日志、统一的异常处理,这才是“优雅接入”的起点。

2. 工程基础设施搭建:依赖、配置与密钥管理

2.1 依赖引入与Spring Boot版本匹配

我这里以阿里云和腾讯云两个主流平台为例,先说依赖怎么加。阿里云短信的新版SDK是独立的dysmsapi模块,从2.0版本开始走的是Tea框架,Maven依赖如下:

<dependency> <groupId>com.aliyun</groupId> <artifactId>dysmsapi20170525</artifactId> <version>2.0.24</version> </dependency>

注意,老项目里常见的aliyun-java-sdk-core加aliyun-java-sdk-dysmsapi那套组合是旧版SDK,初始化方式和请求参数都不太一样。新项目直接用新版dysmsapi20170525就行,文档也更齐全。如果你是在Spring Boot 2.3.x或2.6.x这类版本上做集成,只要依赖本身是通过Spring Boot的依赖管理引入或版本兼容的,一般不会有冲突,但要注意旧版SDK依赖的aliyun-java-sdk-core如果传递到项目里,可能与项目里其他阿里云产品的SDK版本打架。出现过老版本alicloud沙箱依赖把Jackson覆盖掉的情况,建议在新版SDK下排除或锁定版本。

腾讯云短信的依赖写法是:

<dependency> <groupId>com.tencentcloudapi</groupId> <artifactId>tencentcloud-sdk-java-sms</artifactId> <version>3.1.630</version> </dependency>

腾讯云短信的SDK迭代也比较快,版本号找最新的就行。不过要注意,腾讯云的SDK模块比较多,tencentcloud-sdk-java是总包,sms是短信单独的模块,按需引入比引入全家桶干净得多。

2.2 配置文件应该长什么样

很多项目的配置文件里塞了一堆短信配置,命名还乱七八糟——有的叫aliyun.key,有的叫sms.accessKey,有的叫aliyun.accessKeyId,混在一起根本不方便管理。我的做法是把所有短信配置收敛到一个sms前缀下,用平台的子节点分开:

sms: provider: aliyun aliyun: access-key-id: ${SMS_ALIYUN_ACCESS_KEY_ID} access-key-secret: ${SMS_ALIYUN_ACCESS_KEY_SECRET} sign-name: 某某科技 endpoint: dysmsapi.aliyuncs.com template-code: SMS_123456 connect-timeout: 5000 read-timeout: 10000 tencent: secret-id: ${SMS_TENCENT_SECRET_ID} secret-key: ${SMS_TENCENT_SECRET_KEY} app-id: 1400123456 sign-name: 某某科技 template-id: "123456"

用${SMS_ALIYUN_ACCESS_KEY_ID}这种占位符引环境变量,是所有配置方式里比较稳妥的。常见的问题是把密钥直接写在application.yml里还提交到了Git仓库,一旦仓库泄露,密钥就裸奔了。我一个朋友的公司就出过这种事,AccessKey被人拿去刷短信,一天刷了几万条,账单直接爆炸。密钥走环境变量或配置中心,并且定期轮换,这是底线要求。

这里还有两个细节:一是测试环境和生产环境的签名、模板ID要分开,不要混用。测试环境的签名往往带“测试”二字,发到真实手机号上体验很差,而且内容可能触发平台的审核规则。二是超时时间一定要配置,SDK默认的超时往往偏长,短信是强时效的业务,用户等验证码超过一分钟体验就崩了,连接超时和读取超时拆开配置,线上出问题的时候能快速定位是连不上还是响应慢。

2.3 配置类与属性绑定:别再用@Value一把梭

如果项目里到处都是@Value("${sms.aliyun.accessKeyId}"),恭喜你,将来改配置时会想骂人。更好的方式是用@ConfigurationProperties把配置对象化:

@ConfigurationProperties(prefix = "sms") @Data public class SmsProperties { private String provider; private Aliyun aliyun = new Aliyun(); private Tencent tencent = new Tencent(); @Data public static class Aliyun { private String accessKeyId; private String accessKeySecret; private String signName; private String endpoint; private String templateCode; private int connectTimeout = 5000; private int readTimeout = 10000; } @Data public static class Tencent { private String secretId; private String secretKey; private String appId; private String signName; private String templateId; } }

在启动类或配置类上加上@EnableConfigurationProperties(SmsProperties.class),Spring就会自动把yaml配置映射到这个对象。这样做的另一个好处是可以在属性类里做启动校验,比如用@PostConstruct检查必填项,使用@Validated配合@NotNull,项目一启动就发现配置缺失,而不是等到发短信时才报一个莫名其妙的NPE。我一般会在配置类上直接加@Validated,字段上加@NotBlank,配合@ConfigurationProperties的ignoreUnknownFields = false,这样配置写错了也不会被静默忽略。

3. 核心代码实现:可切换的短信发送链路

3.1 抽象接口:把发送语义固定下来

先定义一个统一发送接口,让所有短信平台的差异都被“关”在实现类里:

public interface SmsSender { /** * 返回渠道标识,如 aliyun / tencent */ String channel(); /** * 发送短信 */ SmsSendResult send(SmsSendRequest request); }

请求和结果对象也用统一的结构,业务代码不感知平台差异:

@Data @Builder public class SmsSendRequest { private String phoneNumber; private String signName; private String templateCode; private Map<String, String> templateParams; } @Data @Builder public class SmsSendResult { private boolean success; private String channel; private String requestId; private String bizId; private String errorCode; private String errorMessage; }

这里的设计逻辑是:手机号是强业务字段,必须传;签名和模板Code可以走默认值,也可以由调用方覆盖。默认值从配置中心读取,覆盖功能留给部分营销场景使用。模板参数用Map<String, String>,是因为各平台模板变量的key格式不一致,有的用${code},有的用{1},业务层在拼装时统一转成key-value结构,由实现类去适配平台。

3.2 阿里云实现类:新版SDK的初始化与发送

阿里云新版SDK的Client初始化逻辑和旧版完全不同,核心是用com.aliyun.teaopenapi.Config对象承载配置,然后创建Client。实现类代码如下:

@Component @ConditionalOnProperty(name = "sms.provider", havingValue = "aliyun", matchIfMissing = true) public class AliyunSmsSender implements SmsSender { private static final Logger log = LoggerFactory.getLogger(AliyunSmsSender.class); private final SmsProperties properties; private com.aliyun.dysmsapi20170525.Client client; public AliyunSmsSender(SmsProperties properties) { this.properties = properties; } @PostConstruct public void init() { SmsProperties.Aliyun aliyun = properties.getAliyun(); com.aliyun.teaopenapi.Config config = new com.aliyun.teaopenapi.Config() .setAccessKeyId(aliyun.getAccessKeyId()) .setAccessKeySecret(aliyun.getAccessKeySecret()); config.endpoint = aliyun.getEndpoint(); config.connectTimeout = aliyun.getConnectTimeout(); config.readTimeout = aliyun.getReadTimeout(); try { this.client = new com.aliyun.dysmsapi20170525.Client(config); } catch (Exception e) { throw new IllegalStateException("初始化阿里云短信客户端失败", e); } } @Override public String channel() { return "aliyun"; } @Override public SmsSendResult send(SmsSendRequest request) { SmsProperties.Aliyun aliyun = properties.getAliyun(); com.aliyun.dysmsapi20170525.models.SendSmsRequest req = new com.aliyun.dysmsapi20170525.models.SendSmsRequest() .setPhoneNumbers(request.getPhoneNumber()) .setSignName(request.getSignName() != null ? request.getSignName() : aliyun.getSignName()) .setTemplateCode(request.getTemplateCode() != null ? request.getTemplateCode() : aliyun.getTemplateCode()) .setTemplateParam(buildTemplateParam(request.getTemplateParams())); try { com.aliyun.dysmsapi20170525.models.SendSmsResponse resp = client.sendSms(req); String code = resp.getBody().getCode(); boolean success = "OK".equals(code); if (!success) { log.warn("阿里云短信发送失败, 手机号={}, code={}, message={}, requestId={}", request.getPhoneNumber(), code, resp.getBody().getMessage(), resp.getBody().getRequestId()); } return SmsSendResult.builder() .success(success) .channel(channel()) .requestId(resp.getBody().getRequestId()) .bizId(resp.getBody().getBizId()) .errorCode(code) .errorMessage(resp.getBody().getMessage()) .build(); } catch (Exception e) { log.error("阿里云短信发送异常, 手机号={}", request.getPhoneNumber(), e); return SmsSendResult.builder() .success(false) .channel(channel()) .errorCode("EXCEPTION") .errorMessage(e.getMessage()) .build(); } } private String buildTemplateParam(Map<String, String> params) { if (params == null || params.isEmpty()) { return null; } return JSON.toJSONString(params); } }

这里有个容易踩的坑:新版SDK的SendSmsResponse结构是嵌套的,真正的返回内容在resp.getBody()里,很多人还按旧版的经验直接拿resp.getCode(),结果编译都过不了。另外模板参数必须序列化成JSON字符串,比如{"code":"123456"},如果直接传Map对象或普通字符串,平台解析会报参数格式错误。@ConditionalOnProperty配合matchIfMissing = true的意思是:如果配置文件里没指定sms.provider,默认走阿里云实现,算是一个默认容错策略。

3.3 腾讯云实现类:v3签名机制下的调用方式

腾讯云短信SDK现在默认走TC3-HMAC-SHA256签名,SDK内部封装了完整的签名计算流程,写起来比HTTP裸调要省心很多:

@Component @ConditionalOnProperty(name = "sms.provider", havingValue = "tencent") public class TencentSmsSender implements SmsSender { private static final Logger log = LoggerFactory.getLogger(TencentSmsSender.class); private final SmsProperties properties; private SmsClient client; public TencentSmsSender(SmsProperties properties) { this.properties = properties; } @PostConstruct public void init() { SmsProperties.Tencent tencent = properties.getTencent(); Credential cred = new Credential(tencent.getSecretId(), tencent.getSecretKey()); this.client = new SmsClient(cred, "ap-guangzhou"); } @Override public String channel() { return "tencent"; } @Override public SmsSendResult send(SmsSendRequest request) { SmsProperties.Tencent tencent = properties.getTencent(); SendSmsRequest req = new SendSmsRequest(); req.setPhoneNumberSet(new String[]{"+86" + request.getPhoneNumber()}); req.setSmsSdkAppId(tencent.getAppId()); req.setSignName(request.getSignName() != null ? request.getSignName() : tencent.getSignName()); req.setTemplateId(request.getTemplateCode() != null ? request.getTemplateCode() : tencent.getTemplateId()); req.setTemplateParamSet(buildTemplateParams(request.getTemplateParams())); try { SendSmsResponse resp = client.SendSms(req); SendSmsStatus status = resp.getSendStatusSet()[0]; boolean success = "Ok".equals(status.getCode()); if (!success) { log.warn("腾讯云短信发送失败, 手机号={}, code={}, message={}", request.getPhoneNumber(), status.getCode(), status.getMessage()); } return SmsSendResult.builder() .success(success) .channel(channel()) .requestId(resp.getRequestId()) .errorCode(status.getCode()) .errorMessage(status.getMessage()) .build(); } catch (Exception e) { log.error("腾讯云短信发送异常, 手机号={}", request.getPhoneNumber(), e); return SmsSendResult.builder() .success(false) .channel(channel()) .errorCode("EXCEPTION") .errorMessage(e.getMessage()) .build(); } } private String[] buildTemplateParams(Map<String, String> params) { if (params == null || params.isEmpty()) { return new String[0]; } // 腾讯云模板变量按顺序传,如 {1}, {2} return params.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .map(Map.Entry::getValue) .toArray(String[]::new); } }

腾讯云这里有几个和阿里云不一样的细节:手机号需要带国家码前缀,国内号码要加+86,否则会报手机号格式错误;模板参数不是JSON对象,而是按模板变量顺序传的字符串数组,模板里写的是{1}、{2},对应数组的第一个、第二个元素。这些差异如果不注意,代码看着没问题,跑起来全是平台返回的错误码。

3.4 按配置动态切换:工厂模式还是条件装配

有了多个实现类后,业务层怎么拿到正确的Sender?我见过有人用@Qualifier("aliyunSmsSender")硬编码,这等于又把平台绑死了。两种比较优雅的做法,各有适用场景。

一是条件装配,也是上面实现类里用到的@ConditionalOnProperty。当sms.provider=aliyun时只注入阿里云实现,sms.provider=tencent时只注入腾讯云实现。这种做法简单直接,适合配置一次就不再变的小项目,缺点是如果想做主备切换,两个实现类都在包里,条件装配就玩不转了。

二是工厂模式,把多个Sender按channel登记到Map里,业务层通过渠道名获取:

@Component public class SmsSenderFactory { private final Map<String, SmsSender> senderMap; public SmsSenderFactory(List<SmsSender> senders) { this.senderMap = senders.stream() .collect(Collectors.toMap(SmsSender::channel, Function.identity())); } public SmsSender getSender(String channel) { SmsSender sender = senderMap.get(channel); if (sender == null) { throw new IllegalArgumentException("不支持的短信渠道: " + channel); } return sender; } }

Spring会把所有SmsSender实现类收集到一个List里注入构造函数,工厂类自动完成注册。业务层使用时通过配置或路由逻辑决定走哪个渠道。这种模式下,不需要在配置里写死provider,而是可以在代码里灵活选择,比如默认渠道走阿里云,某个大客户走腾讯云,或者主渠道失败自动切备渠道。我后面的容灾方案就是基于工厂模式做的。

业务层代码长这样:

@Service public class SmsService { private final SmsSenderFactory senderFactory; private final SmsProperties properties; public SmsService(SmsSenderFactory senderFactory, SmsProperties properties) { this.senderFactory = senderFactory; this.properties = properties; } public SmsSendResult sendVerifyCode(String phone, String code) { SmsSendRequest request = SmsSendRequest.builder() .phoneNumber(phone) .templateCode("验证码模板ID") .templateParams(Map.of("code", code)) .build(); return senderFactory.getSender(properties.getProvider()).send(request); } }

3.5 异步发送与线程池:别让短信拖慢主流程

短信接口的响应时间一般在几百毫秒到几秒,如果用户注册接口同步等短信发出,整个请求的RT就被拖长了。而且短信发送失败也不应该直接导致业务主流程失败——用户账号创建成功了,验证码短信因为运营商问题没发出去,这种场景应该通过补偿机制处理,而不是让注册接口报错。

所以短信发送建议异步化。Spring的@Async配合自定义线程池是常规方案:

@Configuration @EnableAsync public class SmsAsyncConfig { @Bean("smsTaskExecutor") public ThreadPoolTaskExecutor smsTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix("sms-task-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }
@Service public class SmsNotifyService { @Async("smsTaskExecutor") public void sendVerifyCodeAsync(String phone, String code) { ... } }

线程池参数里有个容易忽略的点:拒绝策略不要用AbortPolicy,也不要轻易用DiscardPolicy。短信发送不能丢,所以我用CallerRunsPolicy,线程池满了就让调用线程自己跑,宁可慢一点也不能把短信丢了。队列容量也要结合实际流量设置,如果短信量大,可以换更专业的消息队列做削峰,而不是硬扛在一片线程池上。

异步发送带来的另一个问题是调用方拿不到发送结果。如果业务上需要知道短信是否真正发送成功,比如登录场景的验证码校验,那可以考虑FutureTask、CompletableFuture等同步转异步的方式,或者让业务侧走“发送短信”和“校验短信”两个独立接口,不要强制绑定。我在实战中倾向于后者:发验证码是异步行为,校验验证码是独立业务行为,两者解耦。

3.6 发送记录与失败补偿:不可省略的最后一环

短信发送必须有记录。这不是写一行log就完事的事,而是要记录结构化的发送流水:手机号、渠道、模板、参数、请求ID、平台返回码、耗时、结果。我的做法是在发送逻辑里统一埋点,把记录写入发送日志表或消息队列,由下游做统计和分析。

发送失败后还要考虑补偿。补偿的第一层是轻量重试:对返回码表示“平台临时故障”或“网络异常”的失败,隔一段时间重试一次,最多重试两三次。重试时要控制频率,验证码短信如果重试太猛,用户会收到好几条验证码,体验反而差。第二层是告警:发送失败率超过阈值或连续失败一定次数后,要触发告警,人工介入排查。这个告警最好做成独立的监控大盘,能看到每个渠道的发送量、成功率、耗时分布,而不只是等用户反馈“我没收到短信”。

4. 签名与模板:最容易卡审核的两个环节

4.1 签名申请:个人和企业签名的规则差异

签名就是短信开头括号里的那个名字,比如【某某科技】。签名申请看似简单,其实是最容易反复被驳回的环节。常见的驳回原因有几个。

签名内容不符合规范是最常见的。个人开发者申请签名,不能用企业名称、网站名,签名里一般只允许包含“验证码”、“通知”这类通用字眼,比如【验证码通知】。企业签名则要求签名主体与营业执照上的名称相关,比如公司全名叫“某某科技有限公司”,签名可以是“某某科技”。签名里不能包含商标名、门店名这类需要额外证明材料的词,也不能带“优惠”、“折扣”这类营销词。

还有一类驳回原因是签名与模板内容不一致。比如模板内容是营销性质的“限时优惠来袭”,签名却是“某某验证码”,平台一看就对不上,直接驳回。所以一开始就要想清楚:这个签名是给验证码业务用,还是给营销业务用,不同业务最好拆开申请不同签名,不要混用。

4.2 模板变量与内容规范:写错一个括号就审核失败

模板是短信内容的骨架,比如“您的验证码为${code},5分钟内有效”。模板审核的坑主要在变量格式上。阿里云用${变量名},腾讯云用{数字},写错变量格式或变量的个数、顺序不对,平台直接报错。另外一个隐蔽的坑是:模板里不能出现网址链接,如果有链接需求,必须走白名单备案;不能包含“充值”、“贷款”等敏感词,这些词的审核尺度很严。

模板内容里最好也别写太长。短信每条70个字符左右算一条,超过70个字符按多条计费,模板设计时就要算好字数,避免用户收到短信时内容被分割成多条,体验差还费钱。

4.3 语音验证码与短信的差异

有时候业务不只需求短信验证码,还要语音验证码兜底——用户收不到短信时,可以拨打语音电话播报验证码。语音验证码的接入逻辑和短信很类似,也是先申请签名和模板,但有几个重要差异:语音验证码的内容必须是纯数字播报,不能有任何特殊字符(比如星号、括号),模板审核时对内容格式要求更严格;语音验证码的到达率和接通率跟时间段强相关,深夜时段接通率很低,不适合做紧急验证手段。如果业务要接语音,建议单独建一套接口和流程,不要和短信混在一个接口里。

5. 常见问题与排查技巧:直接把错误码甩给你

5.1 高频错误码速查表

短信接入的报错,九成以上集中在下面这张表里,遇到问题时先对照查一遍:

平台错误码含义排查方向
阿里云isv.MOBILE_NUMBER_ILLEGAL手机号不合法检查号码格式、区号、是否多了空格或横线
阿里云isv.SMS_SIGNATURE_ILLEGAL签名不合法确认签名名和已审核通过的签名完全一致
阿里云isv.TEMPLATE_MISSING_PARAMETERS模板参数缺失检查templateParam里的key和模板变量名是否对应
阿里云isv.BUSINESS_LIMIT_CONTROL业务限流触发平台频率限制,检查是否有高频发送行为
阿里云InvalidTimeStamp.Expired时间戳过期检查服务器系统时间和NTP是否同步
腾讯云AuthFailure.SignatureFailure签名计算错误检查SecretId/SecretKey是否正确,SDK版本是否过旧
腾讯云OperationDenied.PhoneNumberInBlacklist手机号在黑名单用户退订过或被投诉过,平台侧已加入黑名单
腾讯云FailedOperation.PhoneNumberIllegal手机号不合法检查是否加了+86前缀,号码段是否正确

InvalidTimeStamp.Expired这个错误非常典型,服务器系统时间如果和标准时间偏差超过一定范围,SDK生成的签名会被判定为过期。排查时先用date命令看看服务器时间,我遇到过不止一次是服务器时钟漂移导致的。黑名单类型的错误则要特别注意:用户一旦投诉过或退订过,平台会把这个号码拉黑,遇到这种失败不是代码问题,不要盲目重试,应该让用户走在线客服或换一种验证方式。

5.2 本地开发怎么调试短信:三个实用方案

本地开发时最怕的就是环境没有短信资质或者测试签名没申请下来。分享三种我常用的调试方案。

第一种是彻底Mock掉Sender。在测试配置里用@MockBean或直接提供一个配置为local的Mock实现,发送逻辑照走,但实际不发短信,只在日志里打印出“模拟发送”和完整参数。这种方案最干净,适合单元测试和绝大部分联调场景。

第二种是用WireMock模拟短信平台HTTP接口。如果平台只提供了HTTP接口文档,你在本地可以用WireMock起一个假接口服务,把平台文档里的请求和响应报文格式照抄进去,然后在本地配置里指向WireMock地址。这种方式的好处是能完整地走一遍HTTP层面的序列化、签名、超时逻辑,比直接Mock掉整个Sender更接近真实链路。

第三种是申请真实的测试签名和测试模板。很多平台允许同一个账号下申请一套测试签名和模板,专门用于开发调试,发送到你的真实手机号上是能收到的。但要注意测试签名和模板的发送频率限制,不要拿测试环境去刷量,否则会被平台风控。我自己的经验是:日常开发用Mock,集成测试用WireMock,月底做全链路压测时再用真实测试签名,这样既不阻塞开发,又能覆盖真实场景。

5.3 线上监控:短信也要有专门的告警

短信接入一时爽,上线运维要跟上。短信的线上监控至少要包含三类指标。

第一类是发送量趋势。正常情况下验证码短信的发送量服从业务规律,某段时间突然暴涨可能有异常——被恶意刷接口、业务活动超预期、定时任务重复触发等。发送量异常告警不需要做复杂的算法,一个“环比上小时增长超过X%”的简单规则就够用。

第二类是发送成功率。按渠道、按签名、按模板分别统计成功率,任何一个维度的成功率跌破阈值都要告警。不要只看总成功率,某个模板的参数拼错可能导致该模板下所有请求都失败,但总量上可能被正常发送掩盖掉。

第三类是账单与余额。余额低于预设阈值就要告警,否则余额扣完短信直接发不出去,用户侧就是“验证码收不到”。另外要关注单条成本的变化,如果计费突然从几分钱变成一毛多,说明有部分消息被拆分成了多条,通常意味着模板内容长度失控了。

6. 再往前走一步:多厂商容灾与防刷策略

6.1 双通道主备切换:短信也能有容灾

短信平台不可能保证100%可用,不管是云厂商的某个区域故障还是通道质量波动,都会直接影响用户收短信。我比较推荐在架构上就做好主备双通道的准备:主渠道和备渠道接入不同的短信平台,正常时流量全走主渠道,主渠道故障时自动切到备渠道。

基于前面设计的SmsSenderFactory,实现容灾比较自然。可以在业务层封装一个SwitchableSmsSender,内部持有主备两个Sender,根据配置或实时健康状态决定用哪个发送:

@Component public class FailoverSmsSender implements SmsSender { private final SmsSender primary; private final SmsSender backup; private final SmsProperties properties; public FailoverSmsSender(SmsSenderFactory factory, SmsProperties properties) { this.properties = properties; this.primary = factory.getSender(properties.getPrimaryChannel()); this.backup = factory.getSender(properties.getBackupChannel()); } @Override public String channel() { return primary.channel(); } @Override public SmsSendResult send(SmsSendRequest request) { SmsSendResult result = primary.send(request); if (!result.isSuccess()) { log.warn("主渠道发送失败, 切换备渠道, phone={}, errorCode={}", request.getPhoneNumber(), result.getErrorCode()); return backup.send(request); } return result; } }

需要注意,不是所有失败都该切渠道。比如手机号在黑名单这种明确不该重试的失败,切到任何渠道都发不出去;模板参数错误这种代码层面的问题,切了也白切。只有在错误码表示“平台故障”、“网络异常”、“限流”这类临时性失败时,才值得触发切换。所以在实现里要对错误码做分类判断,别把切换逻辑做成无脑重试。

6.2 防短信轰炸:短信接口必须有的自我保护

短信接口只要一暴露出去,就必然会被人拿来刷——薅羊毛的、恶意攻击的都有。短信轰炸的典型特征是:同一手机号在短时间内收到大量不同渠道发来的验证码,或者同一接口在短时间内被大量不同手机号调用。防刷的基本手段有三层。

第一层是业务侧频率限制:同一手机号一分钟内最多发一条,一天内最多发若干条,用Redis的INCR和EXPIRE就能实现。第二层是验证码前置校验:发送短信前必须通过图形验证码或滑块验证,挡掉大部分机器请求。第三层是IP限流:同一个IP在单位时间内的短信请求数做上限,换IP攻击的就需要结合设备指纹、行为分析等手段了。

频率限制的粒度要结合业务场景来定。登录验证码可以宽容一点,比如同一手机号一小时最多5条;营销短信则要严格很多,一般一天最多1-2条,而且还受运营商和平台侧的频控管束。防刷策略上线后要持续观察误杀情况——正常用户被频控拦住发不出验证码,比被刷更影响口碑。

说了这么多,从选型、配置、代码到运维,核心思路其实就一条:短信接入要当成一个独立的子系统来做,把平台差异封住,把配置外置,把发送行为记录清楚。我在实际项目中见过很多“能用就行”的短信代码最后都成了事故源,而凡是按这套分层逻辑设计过的项目,换平台、加渠道、排查问题都轻松得多。最后再分享一个小习惯:每次接完短信之后,我都会做一轮“故障演练”,手动把AccessKey改错、把主渠道挂掉、把模板参数漏传,看看系统的表现是否符合预期,这套动作不花多少时间,但真出事的时候能救命。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 3:10:40

Python模拟Enigma转轮机:从原理到CTF暴力破解实战

上周在CTF交流群里&#xff0c;碰到一个朋友卡在转轮机加密的题目上&#xff0c;手里有一段明文和一段密文&#xff0c;要反推转子的顺序和初始位置。他问我这种题是不是只能写脚本硬跑&#xff0c;我说对&#xff0c;而且用Python写一个完整的模拟器和穷举破解器&#xff0c;总…

作者头像 李华
网站建设 2026/10/3 3:10:38

Batch Apktool 3.8.0批量汉化APK全流程解析与避坑指南

最近清理工作目录的时候&#xff0c;翻出一个旧项目——用 Batch Apktool 3.8.0 批量汉化 APK 的整套脚本和笔记。这个需求其实很常见&#xff1a;团队拿到一个只有英文界面的 SDK Demo APK&#xff0c;希望汉化后给内部评审用&#xff1b;或者自己逆向一个开源应用的修改版&am…

作者头像 李华
网站建设 2026/10/3 3:09:34

yum安装Redis实战指南:从换源、配置到安全加固与集群

前几天帮同事排查一台测试服务器&#xff0c;装的CentOS 7&#xff0c;业务那边急着要用Redis做缓存&#xff0c;让我顺手给装一个。我敲下yum install redis -y&#xff0c;回车之后看着终端滚出一堆依赖包&#xff0c;同事在旁边愣了&#xff1a;“这么简单&#xff1f;”我说…

作者头像 李华
网站建设 2026/10/3 3:09:19

LSTM多时间序列融合实现道岔故障诊断实战

简介&#xff1a;本资源是一套基于LSTM神经网络实现多时间序列特征提取的道岔故障诊断系统Python源码及配套实验报告&#xff0c;面向计算机、人工智能、自动化、轨道交通等相关专业的本科生、研究生及工程实践者&#xff0c;解决铁路信号设备中道岔状态实时监测与早期故障识别…

作者头像 李华
网站建设 2026/10/3 3:08:47

Python校园消费数据分析:从饭卡Excel到学生行为画像

简介&#xff1a;本资源是一套高分通过的Python毕业设计实战项目&#xff0c;面向计算机及相关专业本科生&#xff0c;解决校园消费行为数据建模与可视化分析的实际问题&#xff0c;适用于毕业设计、课程设计及期末大作业等场景&#xff0c;代码经导师指导并获99分评审&#xf…

作者头像 李华
网站建设 2026/10/3 3:08:25

纯PHP实现分布式任务调度:Redis队列与Worker架构实战

国内很多团队对 PHP 的定位就是“写网页、出接口”&#xff0c;一说到后台任务、消息队列、分布式调度&#xff0c;第一反应就是上 Java、Go、Python。但实际上&#xff0c;只要你对 PHP 的 CLI 模式、进程模型和选型思路有足够理解&#xff0c;完全可以用纯 PHP 撑起一套稳定、…

作者头像 李华