
做Rails项目这么多年短信接口几乎是每个业务系统绕不开的标配注册验证、登录验证、密码找回、风控通知、订单状态变更全靠那一条短信撑着。但很多人对ruby短信接口的理解停留在找个服务商、发个HTTP请求、完事的程度真上了生产环境才发现验证码偶发收不到、短信延迟到账、被服务商限流、被羊毛党薅短信费每个问题都能让人排查到深夜。这篇文章把我自己在Ruby on Rails项目里做短信集成及后续优化的经验完整梳理一遍覆盖服务商选型、Provider抽象层设计、签名计算、编码与超时、异步化改造、限流防刷、监控排查、测试上线这几个核心环节重点讲清楚每一步背后的取舍逻辑以及那些服务商文档里不会写的实操细节。内容适配Rails 6/7和Ruby 3.x不管你是刚准备接入短信还是已经在用但想优化现有模块应该都能找到用得上的东西。1. 动手写代码之前短信模块要先想清楚三件事短信集成表面上是调用一个API本质上却同时牵扯业务安全、用户体验和成本控制。这三件事如果在设计阶段没有想清楚后面每一行代码都是在给自己埋坑。1.1 验证码的技术约束生成、存储、过期、一次性国内项目里短信流量的大头基本是验证码。验证码看似简单实际上对技术方案有硬性约束。第一验证码的生成必须使用密码学安全的随机源。Ruby里就是用SecureRandomcode SecureRandom.random_number(10**6).to_s.rjust(6, 0)不要用rand不要用时间戳取模不要用什么看似随机的自定义算法。验证码只有6位数字总共100万种组合如果随机源不够好攻击者完全可以预测生成序列那短信验证码就等于形同虚设。第二验证码的状态存储必须考虑分布式部署。网上有大量示例代码把验证码写进session这在单机开发环境没问题一旦上线多实例部署用户请求打到另一台机器就校验不过去。正确做法是存Rediskey设计为sms:verify:手机号value存验证码TTL设5分钟。校验时用Lua脚本或者get del保证读取后立即删除确保验证码一次性使用。这个一次性很关键不然用户反复提交同一个验证码就能绕过某些逻辑。第三验证码模板的签名。国内服务商要求所有短信都必须带签名和模板签名是【某公司】这种格式模板是您的验证码是${code}5分钟内有效。签名需要提前在服务商控制台报备审核模板也要审核。这块经常被开发忽略等到上线前一天才发现签名还没过审项目就得干等。1.2 通知短信和营销短信是两个物种项目里的短信需求一般分三类验证码、通知、营销。很多人用一个SmsService类里的if分支来区分我强烈不建议这么做这三个场景的服务商通道、模板策略、发送时间、合规要求完全不同。验证码要求实时性最高用户正等着登录晚1分钟都是事故。通知类短信订单状态、发货提醒允许稍微延迟但要求稳定不能漏。营销短信则是另一个世界——国内服务商对营销短信有严格的时间限制通常晚8点到早8点禁止发送、退订管理、内容审核甚至对发送频率和单日总量都有约束。在代码层面这三类短信我建议直接建模成不同的发送场景通过Provider的配置区分。比如验证码走验证码专用通道、通知走通知通道、营销走营销通道。这样某类通道出问题需要切换服务商时改动被限制在一个局部不会影响全局。1.3 先定义发不出去的处理策略接入短信之前产品层面有个问题必须先有答案服务商返回失败时用户界面怎么表现我在第一个Rails项目里就是没想清楚这个问题服务商偶发超时用户点击获取验证码后页面一直转圈最后弹一个发送失败。用户当然不爽更麻烦的是他马上再点一次又把频控计数加了一次——明明第一次没发出去第二次却被限流挡掉用户彻底被卡死。后来我改成这样Controller层只负责请求接收成功的返回。先做频控检查通过后生成验证码、写入Redis、投递异步任务然后立即返回验证码已发送。异步任务里如果发送失败会重试两次仍然失败则把失败事件写入监控表把Redis中的验证码标记为expired用户再次点击时重新走流程即可。用户看到的始终是已发送但后台完整记录了真实状态运维能第一时间发现通道故障。2. Provider抽象层代码里永远不要写死某一个服务商2.1 为什么服务商SDK不能直接散落在业务代码里国内主流短信服务商的API风格差异巨大。阿里云要求所有请求参数按字典序排列后用HMAC-SHA1签名腾讯云是TC3-HMAC-SHA256签名签名过程要拼接canonical request、signed headers等一堆中间产物还有一些中小服务商用的是简单的MD5签名甚至直接明文密钥。这些差异意味着如果业务代码直接调用服务商SDK将来换服务商因为价格、稳定性、审核通过率等原因就得把整个项目翻一遍。问题的本质是短信发送对业务代码来说是一个动词它不应该知道底层用的是哪家。我在项目里用一个SmsProvider抽象接口把这块隔离掉。业务代码只依赖接口具体实现通过配置注入。整个抽象层只有三个方法不多不少module SmsProvider def send_sms(phone:, template_code:, params: {}) raise NotImplementedError end def send_verify_code(phone:, code:, expires_in: 300) raise NotImplementedError end def balance raise NotImplementedError end endsend_sms是通用发送send_verify_code是验证码快捷方法内部自动选模板、处理过期时间balance是余额查询。第三个方法看着不起眼但它能救命的——短信服务商是先充值后消费余额不足时接口会静默失败或者直接停止发送等你登录后台一看余额已经是负数了。我习惯加一个定时任务每天调用一次balance低于阈值就告警到企业微信或钉钉群这个成本几乎为零的功能能避免一次用户全部收不到验证码的生产事故。2.2 服务商实现的正确姿势手写HTTP层一个常见的错误是直接把服务商SDK作为Gem放进Gemfile然后全项目require。这么做有三个问题第一服务商SDK的维护质量参差不齐有的半年不更新Ruby版本一升级依赖就崩。我有一次升级Rails 6到7一个短信SDK传递依赖的老版本rest-client直接和新的faraday产生冲突解决依赖花了半天。第二SDK代码不可控一旦服务商接口有小变动比如加了签名版本字段你只能等SDK更新。第三SDK往往引入一堆你用不上的功能无形中扩大了攻击面。我现在的做法是不引入服务商SDK用标准库Net::HTTP或Faraday自己写请求层。以阿里云为例核心代码长这样class AliyunSmsProvider include SmsProvider API_URL https://dysmsapi.aliyuncs.com/ def initialize(access_key_id:, access_key_secret:, sign_name:) access_key_id access_key_id access_key_secret access_key_secret sign_name sign_name end def send_sms(phone:, template_code:, params: {}) query { PhoneNumbers: phone, SignName: sign_name, TemplateCode: template_code, TemplateParam: params.to_json, AccessKeyId: access_key_id, Action: SendSms, Format: JSON, RegionId: cn-hangzhou, SignatureMethod: HMAC-SHA1, SignatureVersion: 1.0, SignatureNonce: SecureRandom.uuid, Timestamp: Time.now.utc.iso8601, Version: 2017-05-25 } query[Signature] AwsStyleSigner.sign(access_key_secret, query) get(query) end end这段代码把一个盒饭级别的需求做成了自己完全可控的实现。服务商接口变了改一行代码重新部署就行。2.3 签名算法唯一值得写一堆单测的地方国内短信接口的签名机制普遍是用密钥对请求参数做HMAC但每家细节不一样。阿里云的规则比较典型所有请求参数按参数名ASCII字典序升序排列拼接成key1value1key2value2前面加上HTTP方法和然后以AccessKey Secret作为密钥做HMAC-SHA1最后Base64编码。有两个细节特别容易翻车中文参数签名名称、模板内容在参与签名前要先做URL编码而且是严格按RFC 3986编码——空格编成%20而不是这个差异很容易让签名结果和服务商不一致。签名用的是AccessKey Secret不是AccessKey ID。把ID当密钥去算结果就是签名不匹配而且报错信息还不直观。签名算法我自己封装成独立模块并且针对服务商官网文档给的示例请求参数写单测。因为签名错误是那种代码看着完全正确、报文也发过去了、服务商就是不接受的玄学问题纯粹靠肉眼排查非常痛苦有单测一次性锁定正确性。3. 发送链路里的细节坑手机号、编码与超时抽象层和签名搞定了接下来是真正发短信时最容易出问题的几个环节。这三块放在一起讲因为它们共同决定了一条短信能不能顺利地送到服务商手里。3.1 手机号归一化全项目统一入口手机号校验看起来简单实际在做项目中我见过太多五花八门的写法有的只判断1开头且11位有的允许带86有的允许空格和横线结果就是同一个手机号注册接口能过、下单接口短信发不出去。我建议在全项目里做一个PhoneNormalizer模块所有入口统一走它module PhoneNormalizer CHINA_MOBILE_REGEX /\A(?:\?86)?1[3-9]\d{9}\z/ module_function def normalize(phone) raw phone.to_s.strip.gsub(/[\s\-]/, ) raw raw.sub(/\A\?86/, ) if raw.start_with?(86, 86) raw end def valid?(phone) CHINA_MOBILE_REGEX.match?(normalize(phone)) end end这个模块做的事情很朴素去掉首尾空格、去掉中间的空格和横线、去掉86或86前缀、然后按号段正则校验。但它带来一个重要的收益——短信发送、用户注册、风控策略、运营报表所有环节看到的手机号格式完全一致不会再出现库里的手机号带86服务商模板变量校验失败这种低级事故。需要说明的是1[3-9]\d{9}这个正则覆盖了目前主流号段但未来如果出新号段比如16、19开头的已存在需要维护这个正则。更好的做法是依赖服务商接口的校验能力把号段判断做成一个辅助校验而不是硬拦截。3.2 超时和重试区分该重试和不该重试短信发送的HTTP调用必须有明确的超时。我的经验值是连接超时3秒、读取超时5秒。这个值既保证了用户体验又给服务商的正常响应留足余量。有些项目不设超时服务商网络一抖动整个请求线程挂在那里用户界面无限转圈这是绝对不能接受的。更关键的是重试策略。服务商返回的错误码大致分两类错误类型示例是否重试参数错误模板不存在、签名不匹配、手机号格式错误不重试修代码业务限制isv.BUSINESS_LIMIT_CONTROL频控不重试等冷却系统错误isv.SYSTEM_ERROR、isp.SYSTEM_ERROR可重试网络超时连接超时、读超时可重试重试我控制在2次以内采用指数退避1秒、2秒。超过3次就不要再试了——连续三次失败问题大概率不在你这边而是服务商通道故障或你的密钥权限出了问题此时应该触发监控告警而不是无限重试给服务商施加更大的压力。3.3 模板参数的JSON与URL编码服务商的模板变量通常要求JSON字符串格式。比如模板您的验证码是${code}调用时要传TemplateParam: {code:123456}。这里有两个隐藏坑参数值里的特殊字符必须转义。如果验证码模板里还要带用户名而用户名里有换行或引号直接to_json可能会产生非法JSON服务商解析失败报模板不匹配。正确的做法是用JSON.generate处理后再序列化。拼URL时所有query参数都要URI.encode_www_form_component特别是中文签名名称和JSON参数。Rails的to_query方法可以帮忙但要注意它默认的编码方式。我在实际项目中遇到过一种诡异情况本地测试一切正常生产环境短信内容乱码排查后发现是网关层Nginx对请求做了二次URL解码导致服务商收到的JSON字符串里的中文被双重编码。最后是直接用POST方法把参数放body里绕开了网关层的URL解析逻辑。所以我的经验是涉及中文参数的短信接口优先用POST form body尽量少用GET拼query string。4. 异步化改造验证码发送绝不能阻塞请求线程4.1 同步发送的代价如果短信发送直接写在Controller的动作里每一次用户点击获取验证码请求线程都要等一次完整的HTTP调用。平时服务商响应300ms看起来还能接受一旦服务商抖动或触发重试接口耗时轻松飙到1秒以上。这个延迟直接叠加在用户感知上而且高并发时Web服务器的线程池很快会被这些同步等待的请求占满。正确方案是异步化。Controller只负责三件事校验手机号、检查频控、生成验证码写入Redis然后投递一个Job到后台队列立即返回。用户端展示验证码已发送实际发送动作在Sidekiq的后台线程里完成。4.2 ActiveJob Sidekiq的任务设计class SmsSendJob ApplicationJob queue_as :sms retry_on SmsProvider::TemporaryError, wait: :exponentially_longer, attempts: 3 def perform(provider_name, phone, template_code, params) provider SmsProviderRegistry.get(provider_name) result provider.send_sms(phone: phone, template_code: template_code, params: params) raise SmsProvider::TemporaryError, result.error_message unless result.success? end end这里queue_as :sms很关键。我的Sidekiq配置里一贯按优先级建三个队列critical短信、支付回调、default普通业务任务、low报表统计类的批量任务。如果所有任务都堆在default里一个跑了10分钟的报表任务会把短信任务堵在后面用户验证码延迟的锅最后还得扣到你头上。4.3 幂等重试的前提是不会重复发送Sidekiq的retry_on保证任务会重试但重试的前提是任务必须幂等。验证码场景的幂等做法是投递Job之前已经在Redis写入验证码Job执行时先检查该手机号是否已有未过期的验证码如果有且没过期就直接跳过发送。这样即使Sidekiq因为某种原因把一个Job执行了两次用户也只会收到一条短信。通知类短信的幂等要按业务维度去重。我的做法是让调用方传入一个业务唯一标识比如订单号Job执行时用SETNX写一个sms:dedup:业务标识的Redis keyTTL设15分钟只有写入成功才真正调服务商接口。这个标识能保证网络抖动引发的任务重跑不会产生重复短信。另外一个容易踩的坑Job里千万别用puts打日志。Sidekiq进程的输出默认可能不进Rails日志出了事故连现场都没有。要用Rails.logger写结构化日志带上手机号脱敏后的标识、模板编号、requestId、耗时这些关键字段。5. 限流防刷短信是最容易被人薅到破产的出口5.1 三层限流设计短信面向C端时有一个残酷的现实短信费是实打实的攻击者可以写一个脚本循环请求你的验证码接口让你的服务商账户余额几分钟内清零。我在项目里做了三层限流每一层解决的问题不同。第一层是单手机号维度。同一手机号60秒内只能发送1条验证码24小时内最多10条。这层直接用Redis的SETNX加过期时间实现代码简洁高效class SmsRateLimiter INTERVAL 60 DAILY_LIMIT 10 def initialize(redis Redis.current) redis redis end def allowed?(phone) interval_key sms:interval:#{phone} return false unless redis.set(interval_key, 1, nx: true, ex: INTERVAL) daily_key sms:daily:#{Date.today}:#{phone} return false if redis.get(daily_key).to_i DAILY_LIMIT redis.incr(daily_key) redis.expire(daily_key, 24.hours.to_i) true end end第二层是IP维度。同一个IP在1小时内验证码请求不能超过20次。移动网络下IP会频繁变化这个阈值可以放宽它的主要价值是拦截同一出口IP的脚本批量刷比如攻击者在云主机上跑脚本出口IP固定这层能精准拦截。第三层是行为特征。比如同一个用户短时间内轮换多个手机号请求验证码、验证码接口的请求来源在短时间内从不同设备跳变这些特征基本可以判定为恶意。检测到直接进黑名单封禁一段时间。这层实现成本高一些但面向C端的项目建议至少做到同一IP多手机号的简单检测。5.2 验证码校验的安全细节验证码的安全隐患往往不在发送环节而在校验环节。我遇到过的问题包括校验接口被暴力遍历。6位数字只有100万种组合如果接口不限制尝试次数攻击者可以在短时间内遍历完。对策是同一个手机号最多连续输入5次错误之后锁定15分钟。验证码不失效。校验成功后必须立即删除Redis中的key确保一次性使用。验证码明文打日志。有时候为了排查方便打印验证码这个举动等于把验证码送给了所有能看日志的人。日志里要记录的是验证码的存在性、过期时间、校验次数而不是明文。频控的阈值参数不要硬编码在代码里放到Rails.application.config或环境变量中方便线上调整。因为频控阈值调多少取决于你的用户量级和短信预算这个参数一定是要能动态调整的。6. 生产环境的监控与排错短信丢了到底怎么查短信集成做完了真正考验人的是上线之后的某一天用户投诉收不到验证码。这个时候如果没有一套完整的日志和状态追踪体系排查会变成一场灾难。6.1 全链路状态机与回执我给每条短信定义一个状态机:pending已投递→sent服务商已接收→delivered用户手机已收到或者pending→failed。服务商接口返回OK只代表它接收了你的请求不代表用户手机收到了短信。真正决定到达率的是运营商回执即服务商通过回调你配置的StatusReport URL把最终的下发状态推给你。这个回执URL要在服务商控制台配置很多开发者会漏掉这一步造成服务商说发了用户说没收到两边对质没有后端数据支持的局面。我习惯在数据库里建一张sms_logs表字段包括id、scenarioverify_code / notification / marketing、phone脱敏、template_code、provider_message_id、status、error_code、error_message、cost、created_at、updated_at。所有发送、回执更新都在这里留痕。配合一个后台查询页面客服接投诉时能直接查到这个手机号这条短信到底走到哪一步。6.2 一次典型的验证码收不到排查过程我总结了一套自己的排查路径分享出来供参考第一查redis中是否还有sms:verify:手机号这个key。如果没有说明用户根本没走到发送逻辑频控被拦或前端没调通问题在前端或业务侧。如果有看TTL是否已过期过期说明发送时间太早用户晚了几分钟才输入。第二查sms_logs表中该手机号的记录。状态是failed直接看error_code按上一节错误分类表处理。状态是sent但没有后续回执说明服务商没有推送回执需要联系服务商客服查provider_message_id对应的原始下发状态。第三确认短信内容是否被运营商拦截。验证码短信内容如果带了营销性质的词汇比如点击链接可能被运营商短信网关策略拦截。这属于玄学问题对策是备一个备用服务商通道切换对比。这个排查链路每层都能快速定位方向不会出现日志一堆、不知道看哪个的情况。6.3 顺带澄清一个搜索迷思:display: ruby写这篇文章时我顺手搜了一下ruby短信接口相关的热词发现很多人被display: ruby带偏了。这里澄清一下display: ruby是CSS的显示属性对应HTML里的ruby、rt、rp标签用来做文字注音排版类似日文假名注音、中文拼音注音跟Ruby编程语言、Ruby on Rails框架没有任何关系。如果你在Rails项目里集成短信搜到的应该是ruby短信接口“sms ruby gem”“rails短信验证码”这类关键词如果在写CSS拼音注音才需要display: ruby。两个世界的入口别搞混。7. 测试策略与上线前检查清单7.1 Mock服务商不产生真实短信费用短信模块的测试核心是业务逻辑而不是服务商的网络。我全部用Minitest::Mock或者webmock拦截外部请求测试环境通过FakeSmsProvider把短信内容打印到日志或存入内存数组方便断言class SmsServiceTest ActiveSupport::TestCase test sends verify code via provider do provider Minitest::Mock.new provider.expect(:send_verify_code, SmsResult.new(success: true), [phone: 13800138000, code: 123456]) service SmsService.new(provider) assert service.send_verify_code(13800138000, 123456) provider.verify end end签名算法的单测是必须的用服务商官方文档的示例参数作为固定用例保证算法以后不会被改坏。频控逻辑也要测连续请求是否被拦截、Redis过期后是否恢复、日限达到后是否拒绝。7.2 上线前清单最后列一个我每次上线短信功能前都会过的检查清单每一条都出自实际教训生产环境的AccessKey是否指向正式服务商的正式项目有没有和测试环境的key混用我见过测试key发短信发到真实用户手机上的事故。模板是否在服务商控制台审核通过模板变量名和代码参数名是否完全一致模板里有${code}代码里传了:code以外的key服务商会直接报错。手机号校验规则是否覆盖所有目标用户号段国际手机号的号处理是否正常Redis key是否加了环境前缀比如production:sms:interval:多个环境共用Redis实例时key不加前缀会互相干扰测试环境把生产环境的频控计数冲掉。服务商控制台的回执URL是否配置接收路由是否已部署有没有真实手机号发一条验证短信验证全链路状态流转我的体会是短信集成的大部分生产问题都出在配置环境而不是代码逻辑上。代码写错了能通过测试发现配置错了往往要等线上用户投诉才能暴露。把上面这套从抽象层、异步化、限流到监控的体系搭起来Rails项目里的短信模块才算真正能打。它能保证的不只是短信能发出去更是发不出去的时候你能在10分钟内定位问题用户骂娘的时候你能马上止损。这个投入的性价比在任何一个面向C端的项目里都是值得的。