ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

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 groupIdcom.aliyun/groupId artifactIddysmsapi20170525/artifactId version2.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 groupIdcom.tencentcloudapi/groupId artifactIdtencentcloud-sdk-java-sms/artifactId version3.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 MapString, 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可以走默认值也可以由调用方覆盖。默认值从配置中心读取覆盖功能留给部分营销场景使用。模板参数用MapString, 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(MapString, 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(MapString, 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.provideraliyun时只注入阿里云实现sms.providertencent时只注入腾讯云实现。这种做法简单直接适合配置一次就不再变的小项目缺点是如果想做主备切换两个实现类都在包里条件装配就玩不转了。二是工厂模式把多个Sender按channel登记到Map里业务层通过渠道名获取Component public class SmsSenderFactory { private final MapString, SmsSender senderMap; public SmsSenderFactory(ListSmsSender 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改错、把主渠道挂掉、把模板参数漏传看看系统的表现是否符合预期这套动作不花多少时间但真出事的时候能救命。
返回列表