
做Java后端这几年短信接口是我觉得最容易被低估的一类对接工作。很多新手以为Java短信接口就是调一个HTTP接口、传几个参数、收到success就完事可一旦涉及签名、模板变量、状态回执、重试补偿、回调验签、通道切换这些东西足够写成独立的一个短信模块。今天这篇就围绕Spring Boot项目如何接入短信平台把我实际踩过的一些坑和沉淀下来的工程化写法整理出来。这篇内容适合谁看一种是正在给公司项目接短信验证码、通知消息的同学另一种是准备把散落各处的短信发送逻辑收敛成统一服务的人。我不会只贴一段能跑的代码还会把参数、签名、回调、幂等、并发这些容易出问题的点一起讲清楚。1. 先说清楚短信接口到底在解决什么问题1.1 业务场景远比“发一条消息”复杂短信在业务系统里通常分三类验证码短信、通知短信、营销短信。验证码短信对时效和成功率要求最高用户点一次“获取验证码”最多等几秒钟短信没到就骂人通知短信更像是系统内的异步消息比如下单成功、发货提醒、余额变动营销短信则对内容合规和频率限制非常敏感发多了容易触发运营商拦截。从代码层面看不管哪一种场景最终做的都是同一件事把手机号、模板ID、模板变量、签名这些数据组装好通过短信平台提供的HTTP接口发出去再接收平台回传的结果。难的不是这个发送动作而是发送动作前后的治理工作。你有没有想过这些问题发送失败要不要重试重试会不会导致用户收到两条验证码回调通知到了怎么确认它真的来自短信平台多个平台之间怎么切换这些才是短信接口真正需要解决的问题。1.2 为什么选择Spring Boot做短信接入层在做技术选型时很多团队会纠结要不要用Spring Boot。我的判断是如果项目本身已经是Java技术栈Spring Boot就是最自然的选择。Spring Boot提供了三层关键能力。第一是配置管理application.yml里写一遍短信平台的AppId、AppSecret、签名、接口地址通过ConfigurationProperties绑定成对象无需到处散落魔法值。第二是HTTP调用能力虽然官方SDK各有不同但统一使用RestTemplate或WebClient封装替换平台时只改一层。第三是生态整合短信发送后的异步处理、成功率和耗时的Metrics采集、失败后的Redis重试队列这些都可以直接复用Spring家族组件。需要提醒的是Spring Boot 2.x和3.x在某些细节上差异不小。如果项目还在用2.3.x或2.6.xRestTemplate的构造方式、spring.factories机制、javax包名改成jakarta这些都会影响代码写法。我的建议是新建项目优先用Spring Boot 3.x老项目升级时不要顺手做短信模块重构一次只改一件事。1.3 自研封装还是直接引平台SDK短信平台一般都会提供Java SDK很多人图省事直接在业务代码里new一个客户端然后到处调用。我不建议这样干。直接引SDK的问题是耦合平台A的SDK、平台B的SDK接口风格完全不同今天接A平台明天要切到B平台业务代码跟着改到崩溃。所以更合理的做法是可以引SDK但必须在SDK外面再包一层自己的SmsSender门面。业务方只依赖你定义的方法不依赖任何一家平台的SDK类型。这里有一个取舍方案优点缺点适用场景直接用平台SDK上手快官方维护平台强耦合切换成本高一次性小项目不打算演进自研HTTP对接完全可控依赖少需要处理签名、回调工作量大对接多个平台或平台SDK质量差SDK自研门面封装兼顾效率和扩展会多一层抽象需要设计能力中大型项目建议首选我个人倾向第三种。就算只接一个短信平台也要把门面接口定义好。短信这个东西早晚会遇到“双通道容灾”需求提前做一层抽象不会亏。2. 接入前必须吃透的参数与签名机制2.1 平台参数和几个容易混淆的概念短信平台的核心参数就那么几个AppId应用标识、AppSecret密钥、短信签名、模板ID。这里有两个“签名”容易把人绕晕。一个是页面里配置的“短信签名”就是用户收到的短信开头那段【某某公司】。这不是代码里算出来的而是在短信平台后台申请审核通过后才能用。另一个是“请求签名”这是每次调用接口时按规则计算出来的一个字符串用来证明这个请求确实来自你请求参数没有被篡改。我见过不少同学在代码里把“短信签名”直接当成“请求签名”传给接口结果回调永远验签失败。写代码前最好在注释里明确区分signature表示短信内容签名sign表示请求签名。2.2 请求签名的计算原理与常见坑不同短信平台的签名算法大同小异核心套路是把请求参数按字典序排序拼成keyvaluekeyvalue这样的字符串再用AppSecret做HMAC-SHA256或MD5最后转成十六进制。以HMAC-SHA256为例private String buildSign(MapString, Object params, String secret) throws Exception { // 1. 使用TreeMap保证参数按字典序排列 TreeMapString, Object sortedParams new TreeMap(params); StringBuilder sb new StringBuilder(); for (Map.EntryString, Object entry : sortedParams.entrySet()) { if (entry.getValue() null) { continue; } sb.append(entry.getKey()).append().append(entry.getValue()).append(); } sb.deleteCharAt(sb.length() - 1); // 2. 用AppSecret做HMAC-SHA256 Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec keySpec new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), HmacSHA256); mac.init(keySpec); byte[] bytes mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8)); // 3. 转成十六进制 StringBuilder hex new StringBuilder(); for (byte b : bytes) { hex.append(String.format(%02x, b)); } return hex.toString(); }这里最容易踩坑的点有三个。第一参数排序一定要用字典序。有些平台要求timestamp和nonce参与签名顺序不对验签必然失败。第二URL编码问题。模板变量里有中文、加号、空格时参与签名的值和实际POST的值必须完全一致。很多平台要求先对值做URLEncoder再签名你这边忘了一步平台那边怎么验都不过。第三密钥不要写死在前端或日志里。签名计算只能发生在服务端AppSecret一旦泄露等于把短信通道交出去了。2.3 模板变量和参数长度短信模板一般长这样“您的验证码是${code}${minutes}分钟内有效”。发送时要把模板变量替换成真实内容。Spring Boot项目里可以先从平台接口拉取模板列表保存到本地缓存也可以本地维护一个模板枚举但最终要以平台审核通过的内容为准。需要注意平台对单条短信长度有硬性限制通常70个字符左右算一条超过会按多条计费。拼接短信内容时尽量把动态部分压缩验证码固定长度通知类内容控制在两三条以内。模板变量的值不要带换行符和特殊符号否则很容易被运营商强制拦截。3. Spring Boot接入短信平台从零到可上线的工程化落地3.1 创建项目和基础依赖无论你用的是IntelliJ IDEA社区版还是旗舰版都可以去Spring Initializr网站下载一个Spring Boot项目。注意IDEA社区版本身没有Spring Initializr向导但可以通过网站生成zip再导入完全不影响开发。我这里用一个比较精简的依赖组合dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependenciesWeb用来提供HTTP发送接口和接收回调Validation用来校验手机号和请求参数Redis用来做幂等去重和发送频率控制。如果项目里已经有用Redis直接复用即可。3.2 配置类别再把密钥散落在代码里首先在application.yml里维护短信平台的连接信息sms: endpoint: https://api.example-sms.com/send app-id: your-app-id app-secret: your-app-secret signature: 【某公司】 connect-timeout: 3s read-timeout: 5s max-retry-times: 3然后定义一个配置属性类Spring Boot会自动完成绑定Component ConfigurationProperties(prefix sms) public class SmsProperties { private String endpoint; private String appId; private String appSecret; private String signature; private Duration connectTimeout Duration.ofSeconds(3); private Duration readTimeout Duration.ofSeconds(5); private int maxRetryTimes 3; // getter / setter 省略 }这种写法的好处是密钥集中管理不会散落到Service里后续接Nacos配置中心时只需要把前缀改成动态配置代码零改动。生产环境里app-secret建议使用环境变量或配置中心的加密能力不要直接提交到Git仓库。3.3 核心发送客户端封装我习惯把“发送一条短信”封装成独立的SmsClient对外只接收业务参数内部完成签名、组装请求、解析响应。发送请求和响应都定义成Java record简洁也不容易出错。public record SmsSendRequest( String mobile, String templateId, MapString, String templateParams, String requestId ) {}public record SmsSendResponse( boolean success, String platformCode, String platformMessage, String platformMsgId ) {}核心发送逻辑长这样Component public class SmsClient { private final RestTemplate restTemplate; private final SmsProperties smsProperties; public SmsClient(RestTemplate restTemplate, SmsProperties smsProperties) { this.restTemplate restTemplate; this.smsProperties smsProperties; } public SmsSendResponse send(SmsSendRequest request) { long timestamp System.currentTimeMillis() / 1000; String nonce UUID.randomUUID().toString().replace(-, ); MapString, Object params new TreeMap(); params.put(appId, smsProperties.getAppId()); params.put(mobile, request.mobile()); params.put(templateId, request.templateId()); params.put(templateParams, JSON.toJSONString(request.templateParams())); params.put(timestamp, String.valueOf(timestamp)); params.put(nonce, nonce); String sign; try { sign buildSign(params, smsProperties.getAppSecret()); } catch (Exception e) { throw new SmsException(短信签名计算失败, e); } HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(X-Timestamp, String.valueOf(timestamp)); headers.set(X-Nonce, nonce); headers.set(X-Sign, sign); HttpEntityMapString, Object entity new HttpEntity(params, headers); ResponseEntitySmsSendResponse responseEntity restTemplate.postForEntity( smsProperties.getEndpoint(), entity, SmsSendResponse.class); return responseEntity.getBody(); } }有几个细节我需要单独强调。templateParams一定要转成JSON字符串不要直接传对象。很多平台的接口格式是{code:1234,minutes:5}这样一层结构你如果直接序列化一个Map字段名和平台要求对不上就会报模板参数异常。另外timestamp和nonce要保证唯一防止请求重放。非ce不用存数据库平台端做防重放校验客户端生成一个UUID就够了。3.4 状态回调通知最容易偷懒出事的一环短信发送成功不等于用户收到。运营商状态报告可能要几十秒甚至几分钟才回来所以成熟的短信接入一定要处理回调。短信平台会把每条短信的状态回执推送到你提供的URL通常长这样PostMapping(/callback/sms/report) public ResponseEntityString receiveReport( RequestBody SmsReportRequest request, RequestHeader(X-Sign) String sign) { // 1. 验签确认请求来自短信平台 if (!smsNotifyService.verifyReportSign(sign, request)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } // 2. 根据requestId找到原始发送记录更新状态 smsSendRecordService.updateReportStatus(request.requestId(), request.mobile(), request.reportStatus(), request.errCode()); // 3. 固定返回 success告诉平台本次回调已收到 return ResponseEntity.ok(success); }回调处理有三个硬性要求必须验签、必须用IP白名单加固、必须立刻返回结果。很多同学会在回调里做重业务逻辑比如更新统计、发MQ结果处理耗时太长导致平台超时重推。我的建议是回调接口只做“接收、验签、落库、返回”后续的统计分析和告警通过异步任务完成。去重也要做平台超时后可能重推同一条状态落库时用requestId做唯一索引。3.5 异步发送、重试与幂等设计短信发送不能做成同步阻塞。用户注册时点一下按钮如果短信平台慢了两秒整个接口就跟着慢两秒。合理做法是业务Controller立刻返回“发送中”真正发送放到线程池里异步执行。Spring Boot里最简单的做法是Async但要单独配置线程池不要复用Tomcat的业务线程池EnableAsync Configuration public class SmsAsyncConfig { Bean(smsExecutor) public Executor smsExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(sms-executor-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }关于重试我的经验是“只重试可重试的异常”。网络超时、平台返回5xx这种可以重试但业务报错比如模板不存在、签名未审核重试一百次也没用。重试要配合指数退避不要一失败就立刻打第二次否则容易雪上加霜。幂等怎么设计最简单的方法是给每次发送生成一个requestId发送前先查Redis如果这个requestId已经处理过直接返回上次结果。验证码场景更要严格控制发送频次同一手机号一分钟最多一条一天最多N条。频率控制用Redis的INCR配合过期时间就能实现。4. 高频踩坑实录与问题排查速查4.1 发送成功但用户收不到怎么办这是排查工作量最大的一个问题。平台返回成功不代表手机一定能收到短信。按照我平时的经验排查顺序应该是这样先看发送的内容是否合规。带有营销词、诱导语、特殊符号容易被运营商拦截。再看状态回调。如果回调里长时间没有DELIVRD这类成功状态多半是被拦截或手机号问题。再看手机号。号码前是否有86、86有些平台对号段校验很严格虚拟号段或携号转网用户也容易出现收不到。最后要确认签名。短信签名没有审核通过或者跟模板不匹配内容可能直接被丢弃。这类问题平台端查不到只能靠状态报告定位。4.2 接口报错先看HTTP状态再看业务码对接短信接口时我建议把两类错误分开处理。第一是HTTP层错误比如401、403、429第二是短信平台返回的业务错误码比如签名错误、模板内容不匹配、余额不足。下面这个表是我整理的一个通用排查思路现象最常见原因处理建议HTTP 401AppId或AppSecret错误检查配置中心配置项HTTP 403IP不在白名单或签名校验失败确认服务器出口IP已加白HTTP 429触发频控查发送记录是否有重复提交平台返回“模板不匹配”模板变量名称和数量对不上逐个字段核对变量名平台返回“签名无效”短信签名未审核或格式错误去平台后台上传资质审核平台返回“余额不足”套餐用尽设置余额阈值告警平台返回“手机号格式错误”号码带了86或空格统一格式化成纯数字排查任何短信问题时都养成一个习惯先把请求参数、响应结果、回调记录完整打到日志里。没有日志遇到问题只能靠猜这是效率最低的排查方式。4.3 并发场景下的发送记录表设计短信记录不只是用来对账的还承担着幂等和审计功能。一张比较实用的sms_send_record表应该包含这些字段id、request_id、mobile、template_id、content、channel_code、status、send_time、callback_time、report_status、error_code、error_msg。其中request_id必须加唯一索引这是防止重复发送的关键。mobile加普通索引方便查某个用户的所有短信记录。status和send_time建议做组合索引用于补单任务扫描长时间处于“发送中”的数据。我通常会在发送前先插入一条status0的记录表示待发送异步任务真正发送成功后更新为status1回调回来再更新report_status。这样即使进程崩溃也能通过定时任务捞起那些发了一半的数据重新补偿。4.4 密钥管理和数据安全短信账号的AppSecret如果泄露攻击者可以用你的账号大量发短信直接造成经济损失和用户骚扰。所以密钥管理不是小事情。我的处理原则是开发环境用本地配置文件测试和预发环境用环境变量注入生产环境必须走配置中心加密或密钥管理系统。日志里打印请求报文时把AppSecret和sign打码只保留前几位。还有一点容易被忽略回调URL不要用GET接口必须用POST并且在网关层限制只允许短信平台的回调IP访问。5. 工程化进阶多通道切换与可观测性5.1 定义统一发送通道接口当项目从单短信平台走向多通道容灾时就要开始考虑抽象了。先定义一个SmsChannel接口public interface SmsChannel { String channelName(); SmsSendResponse send(SmsSendRequest request); boolean supports(String channelCode); }每个短信平台实现这个接口比如AliyunSmsChannel、TencentSmsChannel、CloudMasChannel。在Spring Boot里把实现类自动注入到一个MapString, SmsChannel里channelName()作为Map的key。这样上层SmsGatewayService可以根据配置的channelCode路由到具体实现平台切换对业务透明。这个设计的价值在故障时最能体现。主通道API连续报错运维只要把配置里的channelCode切一下或者路由权重改成0瞬间切到备用通道不用重新发版业务用户完全无感。5.2 多网关路由策略多通道不能只是“一个坏了换另一个”还要考虑正常情况下的分流策略。比如主通道价格便宜一般短信走主通道验证码短信对成功率要求高可以指定走高可用通道。路由逻辑可以基于优先级也可以基于权重甚至可以根据手机号段做分片。路由结果一定要在日志里输出本次短信走了哪个通道、响应耗时、平台返回的msgId。否则等到故障时你根本不知道消息到底发给谁了。日常做通道拨测也很重要每隔几分钟往自己的手机发一条测试短信成功率低于阈值就自动告警。5.3 可观测性日志、指标、告警短信是用户直接感知的强依赖能力不能让它成为黑盒。我建议项目中至少做好三件事。第一日志中贯穿requestId。发送请求、平台响应、状态回调都打同一个requestId出问题时一条命令就能把所有日志捞出来。第二核心指标采集。通过Prometheus或Micrometer统计发送总量、成功率、平均耗时、各通道分发量这些指标能提前暴露通道质量下降。第三告警规则。成功率低于98%、回调延迟超过5分钟、某个通道连续报错都应该触发告警。我个人实际运营下来觉得短信模块最怕的其实不是代码写得多复杂而是出了问题找不到链路。只要日志、指标、告警这三件套齐全绝大多数短信异常都能在用户投诉之前被定位到。最后分享一个小经验接入任何短信平台上线前一定要做一次故障演练。故意把AppSecret改错看看报错链路是否清晰把回调URL停掉观察平台重推机制是否可靠把主通道切到备用通道确认业务无感。这些演练花不了多少时间但能在真正出事时让你从容不少。