ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Spring Boot短信接口对接实战:从选型到生产环境避坑指南

Spring Boot短信接口对接实战:从选型到生产环境避坑指南 1. 先别急着写代码短信服务商的选型逻辑和那些被忽略的坑做Java后端的朋友迟早都会接到短信需求。可能是用户注册时的验证码可能是登录二次校验也可能是订单通知、营销触达。我第一次接到短信接口对接任务时第一反应是这有什么难的找个服务商SDK调一下就行了结果真正落地的时候才发现短信接口远不止调一个API这么简单。这篇内容我就以Spring Boot项目为例把从零对接短信接口的全流程拆开来讲包括服务商如何选、配置怎么管、验证码怎么存、异步发送怎么做、回调怎么接、生产环境还有哪些必须处理的细节。适合刚接触短信开发的后端工程师也适合准备在自己的项目里接入短信验证码功能的朋友参考。先说结论短信服务商选型基本决定了你后面80%的踩坑体验。市面上主流的国内服务商就是阿里云、腾讯云、华为云这几家还有一堆中小型短信平台。我自己的判断标准就三条第一是到达率和稳定性第二是SDK质量和文档完善度第三是审核速度和售后响应。价格其实反而没那么关键短信本身就不贵一条几分钱真正贵的是你上线之后发现到达率低、用户收不到验证码导致流失的隐性成本。我最终选的是阿里云的短信服务一是因为他家SDK更新积极Java调用方式比较统一二是文档和示例相对完善遇到底层报错能查到解决方案三是签名和模板审核有独立的控制台入口操作路径清晰。腾讯云我也用过主要是它有个很实用的能力——正文模板内支持变量动态拼接做营销类通知很方便但如果你只是做验证码场景阿里云和腾讯云差别不大。选型阶段还有两个前置工作必须提前做因为它们的审核周期会直接影响你的上线计划。一个是申请短信签名一个是申请短信模板。签名就是用户收到短信时看到的发送方名称比如某某科技模板就是你短信正文的格式比如您的验证码为${code}5分钟内有效。审核时长一般是一到两个工作日如果涉及行业资质可能更久。所以我的建议是项目启动第一天就把签名和模板申请提交了不要等代码写完了再申请否则你只能干等着审核结果白白浪费时间。注意签名和模板的命名是有规范的个人开发者可选的签名类型通常只有APP应用和公众号/小程序企业用户可以有公司全称/简称等更多类型。模板内容不要出现营销敏感词比如加微信点击链接领取红包这类否则大概率被驳回。2. 工程地基Spring Boot项目如何把短信配置做成一个干净的后端模块服务商定了、签名模板审核通过了接下来才是正式写代码。这里我建议从第一步就考虑好工程结构不要直接把发送逻辑散落在Controller里。原因很简单短信功能涉及配置管理、参数校验、调用服务商、结果处理、日志记录、重试策略多个环节任何一环写肿了后续维护都很难受。2.1 Maven依赖怎么加新旧版SDK到底选哪个阿里云短信的Java SDK目前有两个路线。老一代的是aliyun-java-sdk-core加aliyun-java-sdk-dysmsapi代码写起来比较绕需要手动组装Request对象很多老项目里能看到它。新一代的是com.aliyun:dysmsapi20170525基于tea-openapi框架构建用起来明显更顺手参数链式赋值日志也更清晰。我建议新项目直接上新版SDK别因为网上老教程多用旧版就跟着踩坑。dependency groupIdcom.aliyun/groupId artifactIddysmsapi20170525/artifactId version2.0.24/version /dependency这个版本号不一定是当前最新的你可以在Maven中央仓库确认一下。需要注意一个点新版SDK传递依赖了com.aliyun:tea-openapi和com.aliyun:tea如果你的项目里已有老旧的阿里云SDK版本可能产生类冲突。解决办法是统一升级到该SDK依赖的最新版本或者排除掉冲突传递依赖。2.2 配置文件里到底该放什么不该放什么短信配置的核心是AccessKey ID和AccessKey Secret这两个东西几乎等价于你账号的钥匙。密钥一旦泄露别人就能拿你的账号发短信导致的直接后果是余额被刷光更严重的是签名和模板可能被标记为恶意从而被服务商封禁。我见过不少项目把AccessKey Secret直接明文写在application.yml里还给提交到Git仓库。这是非常危险的习惯。即使只是内网项目也不建议这么做。现在阿里云控制台里可以直接创建子用户AccessKey并限制其权限只能调用短信服务。生产环境更建议配合配置中心比如Nacos或Apollo把明文密钥放在配置中心并做权限控制本地application.yml只保留开发环境的测试密钥。spring: application: name: sms-service aliyun: sms: # 生产和测试环境使用不同的key通过profile切换 access-key-id: ${SMS_ACCESS_KEY_ID} access-key-secret: ${SMS_ACCESS_KEY_SECRET} sign-name: 某某科技 template-code: SMS_123456789 endpoint: dysmsapi.aliyuncs.com我习惯把AccessKey等敏感配置通过环境变量的方式注入这个做法在Docker部署和Kubernetes部署时尤其方便。你只需要在运行环境中设置好环境变量代码里用${SMS_ACCESS_KEY_ID}占位Spring Boot会自动完成替换。2.3 配置绑定和客户端封装要让调用方无感知配置绑定我一般会建一个SmsProperties类用ConfigurationProperties自动绑定配置项。这样做的好处是后续无论是切换服务商还是增加签名和模板的映射关系只需改动配置和这一个类业务代码完全不受影响。Component ConfigurationProperties(prefix aliyun.sms) Data public class SmsProperties { private String accessKeyId; private String accessKeySecret; private String signName; private String templateCode; private String endpoint; }然后是客户端初始化。新版SDK的初始化方式如下Configuration public class SmsClientConfig { Bean public com.aliyun.dysmsapi20170525.Client smsClient(SmsProperties properties) { com.aliyun.teaopenapi.models.Config config new com.aliyun.teaopenapi.models.Config() .setAccessKeyId(properties.getAccessKeyId()) .setAccessKeySecret(properties.getAccessKeySecret()); config.endpoint properties.getEndpoint(); try { return new com.aliyun.dysmsapi20170525.Client(config); } catch (Exception e) { throw new RuntimeException(初始化短信客户端失败, e); } } }这里有一个经验Client是线程安全的可以全局复用不要每次发送都new一个。如果你参照网上某些教程把Client放在方法里创建在高并发场景下你会频繁创建连接既浪费资源又增加了超时概率。3. 核心发送逻辑这样设计验证码场景的存储、校验与异步发送一步到位短信发送的代码本身不难难的是把发送逻辑设计得扛得住线上真实压力。这部分的重点不只是调通接口而是想清楚验证码怎么存、怎么校验、重复发送怎么拦截、接口超时怎么办。下面这三个小节是我做短信功能时沉淀下来的核心设计。3.1 发送短信的主流程代码这样写最不容易出问题一条短信的发送逻辑从业务侧看很简单手机号、签名、模板、模板参数四样东西齐了就能调。但实际工程里我们还要在调用服务商之前做好参数校验避免把乱七八糟的数据传给第三方。Service public class SmsServiceImpl implements SmsService { Resource private Client smsClient; Resource private SmsProperties smsProperties; Resource private StringRedisTemplate redisTemplate; Override public void sendSmsCode(String phone) { // 1. 参数合法性校验手机号格式不对直接拒绝 if (!PhoneValidator.isValid(phone)) { throw new BizException(手机号格式不正确); } // 2. 防刷校验同一手机号60秒内只能发一次 String sendFlagKey sms:send:count: phone; Boolean absent redisTemplate.opsForValue() .setIfAbsent(sendFlagKey, 1, Duration.ofSeconds(60)); if (Boolean.FALSE.equals(absent)) { throw new BizException(发送过于频繁请稍后再试); } // 3. 生成6位随机验证码 String code String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)); // 4. 验证码先入库这里用Redis设置5分钟有效 redisTemplate.opsForValue() .set(buildCodeKey(phone), code, Duration.ofMinutes(5)); // 5. 异步调用服务商发送 asyncSendSms(phone, code); } Async(smsExecutor) protected void asyncSendSms(String phone, String code) { try { com.aliyun.dysmsapi20170525.models.SendSmsRequest request new com.aliyun.dysmsapi20170525.models.SendSmsRequest() .setPhoneNumbers(phone) .setSignName(smsProperties.getSignName()) .setTemplateCode(smsProperties.getTemplateCode()) .setTemplateParam({\code\:\ code \}); com.aliyun.dysmsapi20170525.models.SendSmsResponse response smsClient.sendSms(request); if (!OK.equals(response.getBody().getCode())) { log.error(短信发送失败, phone:{}, code:{}, resp:{}, phone, code, response.getBody()); } } catch (Exception e) { log.error(短信发送异常, phone:{}, phone, e); } } }简单解释一下setIfAbsent是Redis的原子操作当key不存在时才会写入利用它可以直接实现60秒内不能重复发送的防刷逻辑。验证码先写入Redis再异步发送是因为用户体验优先——接口要快速返回不能卡在第三方网络请求上。至于异步发送失败怎么办下一节细说。3.2 验证码的存储与校验正是体现后端功力的时候短信验证码最常见的场景是用户注册或登录时的校验。它的核心诉求有三个验证码不能明文存数据库、必须有过期时间、校验时要能防止暴力尝试。用Redis存验证码是目前最主流的做法。我把验证码的key设计成sms:code:{phone}value直接存6位数字。校验的时候取出Redis里的值和用户提交的验证码比较相等则校验通过并立即删除一次性使用不相等则记录失败次数超过5次就删除验证码要求用户重新获取。Override public boolean verifySmsCode(String phone, String code) { String key buildCodeKey(phone); String cachedCode redisTemplate.opsForValue().get(key); if (cachedCode null) { throw new BizException(验证码已过期请重新获取); } // 校验失败次数防止暴力破解 String failKey key :fail; Long failCount redisTemplate.opsForValue().increment(failKey); if (failCount ! null failCount 5) { redisTemplate.delete(key); redisTemplate.delete(failKey); throw new BizException(尝试次数过多请重新获取验证码); } if (cachedCode.equals(code)) { redisTemplate.delete(key); redisTemplate.delete(failKey); return true; } redisTemplate.expire(failKey, Duration.ofMinutes(5)); throw new BizException(验证码错误); }这里有个容易被忽视的细节验证码是敏感信息不管在日志还是数据库里都不能以明文形式整体出现。非要记录日志建议只打印后四位或者做脱敏处理。因为验证码短信一旦被中间人截获或日志泄露攻击者可以直接用来重置密码、登录账号。3.3 异步发送和重试机制是短信接口稳定性的关键保障短信发送依赖第三方服务商的网络大概率会出现偶发性超时或失败。如果发送逻辑写成同步用户请求就会一直转圈如果失败后不做任何处理用户就永远收不到验证码只能重新触发发送。这两者都是糟糕体验。我的方案是使用Spring的Async注解配合自定义线程池将短信发送从主线程中剥离。实际项目中我通常单独定义一个SmsExecutorConfigConfiguration public class SmsExecutorConfig { Bean(smsExecutor) public ThreadPoolTaskExecutor smsExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(sms-exec-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }为什么用CallerRunsPolicy而不是直接丢弃因为当任务队列满了说明系统短时间内的短信请求量已经很大这时候用调用者线程执行相当于天然限流避免任务静默丢失。同时我在异步发送方法内重试两次第一次间隔2秒第二次间隔4秒。如果两次重试都失败就打WARN级别日志并标记一条失败记录后续通过定时任务补偿。这里要额外注意Async生效的前提是调用方和被调用方不能是同一个类内部调用且必须是Spring Bean。所以我把asyncSendSms设计成了protected方法从sendSmsCode里跨类调用如果是同一个ServiceImpl内部调用会出现自调用不代理的问题异步注解失效。初次接触Spring异步的朋友经常在这里踩坑以为注解写上就行结果发现还是同步执行就是这个原因。4. 跑通本地联调和回调接收如何让短信真正发出去并拿到状态报告代码写完了怎么验证逻辑对不对最直接的方式就是写一个测试Controller传入真实手机号看短信能不能收到。但这一阶段有些细节值得说一说因为很多人卡在本地调不通回调接不到这类问题上。4.1 本地测试环境如何配置避免误发线上短信短信是花钱的服务本地开发联调时如果每次都用真实手机号发送一天下来费用也够点几顿外卖了。阿里云控制台提供了测试专用签名和测试模板申请通过后测试模板有固定的格式比如您的验证码为${code}您正在使用测试模板发送短信。用测试模板发短信不会真正触达用户手机号码而是在控制台的短信记录中看到发送详情。如果你确实需要在自己手机上收到真实短信那就必须用已审核通过的正式签名和模板。建议把测试手机号放到配置里只在开发环境使用比如# application-dev.yml aliyun: sms: test-phone: 13800000000Controller接口里可以加一个逻辑当spring.profiles.activedev且手机号不在白名单中时直接返回开发环境仅允许测试号码发送。这样能防止联调时误把测试数据发到真实用户手机里。4.2 回调接口的正确接入姿势短信服务商会异步推送短信的状态报告比如已发送已到达发送失败用户拒收等。这个回调是HTTP POST请求服务商把状态数据以JSON格式POST到你提供的回调地址上。回调接口本质上是接收第三方数据的入口写起来不复杂但有几个安全性和业务性的问题必须处理。RestController RequestMapping(/sms/callback) public class SmsCallbackController { PostMapping(/status) public MapString, Object receiveStatus(RequestBody MapString, Object body) { log.info(收到短信状态回调: {}, body); // 解析手机号、消息ID、状态码、状态描述 String phone String.valueOf(body.get(phone_number)); String messageId String.valueOf(body.get(out_id)); String status String.valueOf(body.get(report_status)); String description String.valueOf(body.get(err_msg)); // 更新本地数据库中的发送记录表 smsReportService.updateReport(messageId, status, description); // 返回success告诉服务商消息已收到否则服务商以为你没收到会重复推送 return Map.of(code, 0, msg, success); } }回调处理的关键点有两个一是必须给服务商返回明确的成功响应否则它们会按自己的重试策略重复推送二是要做幂等处理因为消息可能推多次你的业务表要通过messageId或out_id做唯一约束重复接收时只更新不重复写入。另外回调接口没有用户身份信息的只要URL被猜到任何人都可以往你接口上POST假数据。所以生产环境的回调地址建议加一个请求头校验或签名校验。阿里云的回调支持配置Token接收到请求时校验Header中的Token是否匹配这一步一定不要省。我的做法是自己封装了一个SmsCallbackAuthFilter对所有/sms/callback/*的请求统一校验Token。提醒回调地址必须是公网可访问的HTTPS地址。本地调试时可以利用内网穿透工具把回调地地址临时暴露到公网但在生产环境千万不要图方便继续用这类工具直接配置公网域名加HTTPS才是正解。4.3 手机号加密传输与合规存储短信接口对接的过程中手机号属于用户敏感信息。虽然这不是本篇重点但我强烈建议你在设计短信服务模块时就考虑合规问题。前后端交互时手机号字段最好用加密传输比如AES或国密SM4服务端落库时做脱敏处理。日志打印时手机号只保留前三位和后四位。这个习惯越早养成越好等日后被合规审计要求整改的时候再来逐个字段checkout成本就高了。5. 生产环境不能回避的问题限流、幂等、监控和成本控制测试通过了代码上线了短信功能看起来已经跑起来了。但真实生产环境比测试环境复杂得多短信接口有几个问题如果不在上线前处理迟早会变成线上事故。这章我逐个说。5.1 限流不只是防刷更是保护自己短信接口天然是攻击者的目标——只要能无限调用你的发送接口对方就能用你的短信通道轰炸任意手机号码既刷你的余额也损害你域名的信誉。更可怕的是如果你的发送接口业务逻辑里还带着用户查询或创建操作攻击者甚至可以利用它做批量探测。所以网关层限流是必须的。如果你用Spring Cloud Gateway或Nginx可以直接对/sms/send这类路径做QPS限制。如果是单机应用也可以用Guava的RateLimiter或RedisLua实现分布式限流。我的实践是两层限流第一层是接口级QPS限制比如单机每秒最多200个请求超出直接拒绝第二层是业务级限制即每个手机号每天最多发10条验证码每个IP每分钟最多发5条。// 每天上限限制 String dailyKey sms:daily:limit: phone; Long count redisTemplate.opsForValue().increment(dailyKey); if (count ! null count 1) { redisTemplate.expire(dailyKey, Duration.ofDays(1)); } if (count 10) { throw new BizException(今日短信发送已达上限); }这个限流逻辑本身不会有太高性能消耗Redis的原子自增操作是常数复杂度。但要注意每次发送都做两次Redis操作间隔限制和每日上限高峰期要观察一下你Redis的QPS增长情况如果成为瓶颈可以用本地缓存CaffeineRedis两级方式优化。5.2 发送记录的持久化别等查不到日志才后悔短信发送记录必须落库。我维护过多个短信模块最深的感触是没有发送记录表出现问题排查的成本会高到让人抓狂。用户投诉说没收到短信服务商说已经发出去了如果两边各执一词你手里没有证据链根本无法定位问题。CREATE TABLE sms_send_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL COMMENT 接收手机号, template_code VARCHAR(64) NOT NULL COMMENT 模板ID, template_param VARCHAR(500) COMMENT 模板参数JSON, biz_id VARCHAR(64) COMMENT 服务商返回的消息ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-发送中 1-成功 2-失败, err_code VARCHAR(64) COMMENT 错误码, err_msg VARCHAR(255) COMMENT 错误描述, request_time DATETIME NOT NULL COMMENT 请求时间, report_time DATETIME COMMENT 回执时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_phone_time (phone, request_time) ) COMMENT 短信发送记录表;记录表的意义包含但不限于第一核对服务商账单时用它做总量核对第二排查用户投诉时查它判断短信真实状态第三统计到达率、发送成功率评估不同服务商的通道质量。我在对接初期就设计了这张表后来服务商对账和问题排查都靠它。5.3 监控指标和异常告警短信模块的监控我建议重点关注四个指标发送成功率、到达率、发送耗时、调用量。到达率依赖于回调的及时性如果你发现某个时段到达率低于正常值优先怀疑服务商通道问题及时切换备用通道。发送耗时如果从200毫秒涨到2秒大概率是SDK连接池或网络问题。我每次发布短信相关改动后都会盯着这四个指标看半小时再离开。告警规则上成功率低于95%告警单日调用量环比上涨超过50%告警回执超时超过10分钟告警。告警通道可以用企业微信机器人或钉钉机器人把异常信息推送到群里这样不用等用户投诉就能第一时间感知。6. 回头看这些坑我踩过希望你绕过最后分享几个我在短信接口对接和线上运维过程中踩过的坑每个都对应一个真实的线上教训。第一模板参数必须严格按照模板定义传。阿里云模板变量是通过JSON字符串传递的比如{code:123456}。如果你模板里定义的变量是${name}传参时key就必须叫name大小写都敏感。我有一次把变量名写成Name结果发送报错isv.SMS_TEMPLATE_ILLEGAL排查了半天才发现是大小写问题。第二别把endpoint配错了。新版SDK的endpoint应该是dysmsapi.aliyuncs.com如果配成dysmsapi.aliyuncs.com.cn之类就会出现未知异常。这类配置项我一般直接抄官方文档不要自己拼。第三Spring Boot项目如果同时引入了多个阿里云SDK要特别留意版本兼容问题。旧版aliyun-java-sdk-core和新版dysmsapi20170525共用部分类名可能引发奇怪的NoSuchMethodError。解决方式是尽可能将阿里云所有SDK统一到最新的兼容版本或者在引入时排除冲突的传递依赖。第四异步发送的重试要设置最大次数避免服务商不可用时无休止重试导致线程池被打满。我遇到过一次短信服务商大面积故障重试任务堆积排满了队列直接导致整个应用响应变慢。后来我加了熔断逻辑当连续失败超过20次时暂停短信发送并开启降级开关故障恢复后自动放开。根据我个人的实际操作经验短信接口对接这件事本身不难真正拉开差距的是对异常链路、数据闭环和成本控制的处理。你把这篇文章里的设计思路落地到项目里基本就能避免我踩过的那些坑。如果你正在做Spring Boot集成短信功能的方案设计希望这篇内容能帮你把方案做得更完整。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表