做Rails项目久了,总会碰到一个绕不开的环节:短信。无论是用户注册的验证码、下单后的提醒,还是活动推广的营销消息,短信几乎是每个业务系统里都会出现的常驻角色。我在多个项目里接过不同的短信服务商,从最原始的同步 HTTP POST 调用,到后来接状态回调、异步队列、限流防刷,踩过不少坑,也沉淀出一套可以复用的套路。这篇文章聊聊 Ruby on Rails 项目里做短信接口开发对接的完整要点,包括服务商选型、签名鉴权、异步发送、回调解析,以及几个特别容易被忽略的细节。顺便澄清一个最近高频出现的搜索词——display: ruby 是什么意思——它和 Ruby 语言一点关系都没有,但很多人在搜“ruby 短信接口”时会撞见,下面一并说清楚,免得大家被带偏。
1. 短信集成前必须想清楚的五件事
1.1 你需要的到底是验证码、通知还是营销短信
很多项目一开始就是把短信当成一个“发消息”的功能,拿到服务商账号就开始调接口,结果上线后问题不断。真正动手前,先要搞清楚业务场景属于哪一类,这会影响你对服务商、接口能力、并发量以及合规范畴的判断。
验证码类短信追求的是送达速度和稳定性,用户点“获取验证码”之后希望一两秒就能收到,而且同一个手机号必须做频率限制,防止被刷。通知类短信比如订单状态变更,可以容忍一定延迟,重点是要有可靠的重试和发送记录,不然用户没收到消息容易投诉。营销类短信则完全不一样,它要遵守更严格的运营规范,部分服务商要求强制退订,甚至对发送时间有明确规定。这决定了你在接入时是否需要单独申请营销签名、是否走不同模板。
从技术上来说,这三类场景最终都可以用同一个发送通道,但业务参数、频率阈值、日志分类都应该分开。我在实际项目中把发送记录表加了一个channel字段,用枚举区分三种类型,后续做统计和问题定位都非常方便。
1.2 服务商与接入方式:裸 HTTP 还是官方 SDK
市面上的短信服务商很多,国内常用的大致有阿里云、腾讯云、云片、容联云等,各家接口风格差异不小。接入方式一般有两种:一种是直接用服务商提供的官方 SDK,另一种是自己基于 HTTP API 做封装。
官方 SDK 的好处是开箱即用,签名、鉴权这些细节都被封装好了,文档也比较全,适合团队小、时间紧、不打算维护底层调用的场景。但它也有麻烦:增加了第三方依赖,升级节奏不由你控制,而且一旦服务商 SDK 内部对 Ruby 版本兼容有问题,调试起来相当痛苦。我自己更倾向于用裸 HTTP 调用,用 Faraday 或 Net::HTTP 自己发请求,虽然前期要把签名、序列化这些做一遍,但换来的好处是代码完全透明:超时设置、重试策略、日志记录、多服务商切换都能统一控制。
| 接入方式 | 优点 | 缺点 |
|---|---|---|
| 官方 SDK | 接入快、文档稳定 | 依赖重、调试不透明、升级不可控 |
| 裸 HTTP | 代码可控、便于统一封装 | 要自己处理签名和异常 |
如果你在做一个长期迭代的项目,我建议直接把短信发送抽象成独立的 Service 层,内部选择用哪种实现,外部暴露一个统一的调用接口。这样以后换服务商,或者从裸 HTTP 切到 SDK,都只是改一个适配器的事情。
1.3 密钥和配置绝不落仓库
短信接口的 AccessKey、SecretKey 属于核心敏感信息,一旦泄露,攻击者可以拿你的账号疯狂发短信,直接变成“短信炸弹”。Rails 项目最常见的错误就是把密钥写在config/sms.yml甚至写在application.rb里再提交到 Git。正确的做法是:使用 Rails 6+ 自带的 credentials,或者环境变量。
如果用环境变量,建议在config/initializers/sms.rb里集中读取:
# config/initializers/sms.rb SMS_CONFIG = { provider: ENV.fetch('SMS_PROVIDER', 'aliyun'), access_key_id: ENV.fetch('SMS_ACCESS_KEY_ID'), access_key_secret: ENV.fetch('SMS_ACCESS_KEY_SECRET'), sign_name: ENV.fetch('SMS_SIGN_NAME'), template_code: ENV.fetch('SMS_TEMPLATE_CODE'), callback_url: ENV.fetch('SMS_CALLBACK_URL', nil) }.freeze这里用ENV.fetch而不是ENV[],好处是在启动阶段就能发现缺配置,避免运行时才报出莫名错误。另外所有密钥保存到.env文件后,记得在.gitignore里把.env排除掉,并且加入.env.example作为模板,方便其他同事复制。
1.4 建一个 SmsService 门面,而不是到处直接调用
短信发送很容易被写坏:今天在UsersController里发一条,明天在OrdersController里又发一条,每次传参都不一样,等要统计发送量或者切换服务商时才发现改不动。
我的习惯是建一个app/services/sms_service.rb,统一对外提供方法:
class SmsService def self.send_verify_code(phone:, code:, business_id:) params = { code: code, product: 'yourApp' } send_sms(phone: phone, template_code: SMS_CONFIG[:template_code], template_params: params, channel: :verify_code, business_id: business_id) end def self.send_notification(phone:, template_code:, template_params:, business_id:) send_sms(phone: phone, template_code: template_code, template_params: template_params, channel: :notification, business_id: business_id) end end这样做的核心价值在于:Controller / Job 永远不直接感知服务商差异,只需要传业务参数。日后加一套“每月发送统计”,或者从阿里云换成腾讯云,都只动SmsService内部实现,调用方一行代码不用改。
1.5 内容合规比技术更重要
短信内容需要服务商审核,很多项目第一版就因为“测试”、“恭喜您获得”这类词被打回。正规做法是:在服务商后台申请签名和模板,签名通常是公司名称或产品名,模板内容里的变量用${code}占位。你自己代码里发的参数必须和模板变量完全匹配,多一个少一个都不行。另外不要想着绕过签名或模板直接发“自定义内容”,除了触发封号,还容易变成垃圾短信,得不偿失。
2. 核心对接细节:从请求签名到服务商调用
2.1 理解服务商要求的签名方式
短信接口的鉴权逻辑本质上就一句话:你要证明“我是我”。所有服务商都用 AccessKey / SecretKey,但具体签名算法差异很大。以最常用的阿里云为例,它要求把除了Signature之外的所有请求参数按参数名 ASCII 升序排序,然后构造规范化的查询串,再用 HMAC-SHA1 对GET&%2F&...做签名。关键难点在于签名前每个参数都要做 URL 编码,而且空格必须编码成%20而不是+,*也要特殊处理。
网上很多 Ruby 博客给的代码都简化了编码步骤,直接拿CGI.escape拼字符串,导致签名偶尔失败。我后来统一封装了一个percent_encode方法:
require 'uri' def percent_encode(value) URI.encode(value.to_s, /[^a-zA-Z0-9\-_.~]/) end注意URI.encode的第二个参数指定需要转义的字符,这样可以保留字母、数字和-_.~,其他字符按 UTF-8 百分比编码。签名步骤里的字符串拼接也要严格按服务商文档来,多一个换行都签不上。
如果你不想深挖签名算法,可以直接用官方 SDK 跑通链路,等确认业务可行后再用自己的 HTTP 实现替换也不迟。我建议两条路都走一遍,至少你要能看懂签名过程,否则遇到签名失败排查起来很被动。
2.2 一次发送请求的实际构造过程
用阿里云短信服务举例,一次发送请求的完整参数大概是这样的:
POST https://dysmsapi.aliyuncs.com/ Action=SendSms Version=2017-05-25 Format=JSON RegionId=cn-hangzhou PhoneNumbers=13800138000 SignName=我的App TemplateCode=SMS_123456 TemplateParam={"code":"123456"} AccessKeyId=LTAI... Signature=计算出的签名 Timestamp=2025-04-08T03:20:30Z SignatureNonce=唯一串注意TemplateParam必须是 JSON 字符串,不是 Ruby Hash。有些同学在这里踩坑:直接把template_params.to_s传过去,服务端解析报参数缺失。正确写法是template_params.to_json,并且保证 JSON 里的 key 和模板变量完全一致。
用 Faraday 发送时,可以这样:
require 'faraday' conn = Faraday.new(url: 'https://dysmsapi.aliyuncs.com/') do |f| f.request :url_encoded f.adapter :net_http end response = conn.post('/', params)这里没有加终端超时,但实际生产环境绝对不能裸奔,必须手动设置连接和读取超时。
2.3 超时设置和重试策略
短信 HTTP 调用属于外部依赖,对方接口可能因为瞬时流量出现几秒延迟。你不可能让用户一直转圈等结果。我在所有适配器里严格设置:连接超时 3 秒、读取超时 5 秒。Faraday 的写法是这样:
conn = Faraday.new(url: endpoint, request: { open_timeout: 3, timeout: 5 }) do |f| f.request :url_encoded f.adapter :net_http end超时之后要不要重试,这个问题很微妙。如果是因为网络抖动超时,重试一次通常能成功;但如果是服务商侧的业务错误,重试一万次也没用。我总结了一条原则:
- 网络超时、5xx 错误:可以重试,但最多 2 次,每次间隔 1 秒、2 秒递增。
- 业务错误(Code 返回 isv.XXX):立刻放弃,记日志,异常上报。
- 验证码场景:重试前必须确认第一次请求是否真的失败,否则用户可能收到两条相同验证码。
为了防止重复发送,我在业务层引入business_id,每次用户点击获取验证码时生成一个 UUID,同一笔操作只发送一次。服务商虽然也有MessageId返回,但那是发送成功后的消息标识,不能拿来做幂等。
2.4 把发送放到后台:Sidekiq + ActiveJob
很多人第一版都这么写:用户在 Controller 里点击发送验证码,代码同步调用短信接口,等返回成功后再跳转页面。这在低并发时看不出问题,但一旦短信服务商响应变慢,所有注册、登录请求全部卡住,体验非常糟糕。
正确做法是:Controller 只做参数校验和发送频率校验,然后把发送动作丢给后台任务。Rails 里推荐用 ActiveJob 配合 Sidekiq:
class SendSmsJob < ApplicationJob queue_as :default retry_on Faraday::TimeoutError, attempts: 3, wait: :exponentially_longer def perform(phone, template_code, template_params, business_id) SmsService.send_sms( phone: phone, template_code: template_code, template_params: template_params, business_id: business_id ) end end调用方只需要SendSmsJob.perform_later(phone, template_code, params, business_id)。Sidekiq 最大的好处是自带重试和失败面板,你可以在 Web UI 里看到所有失败的 Job,点开异常堆栈立刻定位问题。另外记得把config/active_job.rb里的默认队列适配器改成:sidekiq,不然开发环境还是同步执行。
2.5 状态回调:校验签名并更新数据库
短信发送是否成功,不能只看发送接口的返回。服务商真正把短信交给运营商之后,会通过回调 URL 通知你最终的送达状态。回调通常是 POST 请求,携带手机号、状态、错误码、签名等字段。你必须先校验回调签名,再更新sms_logs表。
校验签名的方式和发送时一样:取出服务商给你的 SecretKey,对回调参数做同样算法,比对签名是否一致。这里最容易出问题的不是签名本身,而是你的回调接口必须能处理重复通知:服务商在没有收到成功响应时可能重推,所以一定要按provider_message_id做幂等。我一般会先在 Redis 里写入一个短时 key,或者直接在数据库加唯一索引。
3. 一个可落地的 Rails 短信集成实现
3.1 数据模型:从第一天就建好短信日志表
短信日志表是整个集成方案的基石。无论成功失败,每一条发送请求都要落库,否则后面排查问题全靠猜。我建的迁移大致是:
create_table :sms_logs do |t| t.string :phone, null: false t.string :channel, null: false # verify_code / notification / marketing t.string :template_code t.text :template_params t.string :provider t.string :provider_message_id t.string :status, default: 'pending' # pending / success / failed t.string :error_code t.string :error_message t.string :business_id t.string :ip, limit: 64 # 用于风控 t.timestamps end add_index :sms_logs, :business_id, unique: true add_index :sms_logs, [:phone, :created_at] add_index :sms_logs, :status为什么business_id要唯一索引?因为你可能同时在多个进程里发同一条验证码,唯一索引保证同一次请求不会被重复插入。回调更新状态时也以这条记录为目标,做到一条短信一条日志。
3.2 服务类的完整代码骨架
下面给出一个含适配器思想的实现骨架,你可以根据所用服务商自行填充。核心是SmsService负责流程控制,适配器负责协议细节。
# app/services/sms_service.rb class SmsService class Error < StandardError; end def self.send_sms(phone:, template_code:, template_params: {}, channel: :notification, business_id: nil) raise Error, '非法手机号' unless phone.match?(/\A1[3-9]\d{9}\z/) business_id ||= SecureRandom.uuid sms_log = SmsLog.create!( phone: phone, channel: channel, template_code: template_code, template_params: template_params.to_json, provider: SMS_CONFIG[:provider], business_id: business_id, status: 'pending' ) begin result = adapter.send(phone: phone, template_code: template_code, template_params: template_params) sms_log.update!( status: result[:success] ? 'success' : 'failed', provider_message_id: result[:message_id], error_code: result[:error_code], error_message: result[:error_message] ) rescue StandardError => e sms_log.update!(status: 'failed', error_message: e.class.to_s + ': ' + e.message) raise e end sms_log end def self.adapter case SMS_CONFIG[:provider] when 'aliyun' AliyunSmsAdapter.new when 'tencent' TencentSmsAdapter.new else raise Error, "不支持的 provider: #{SMS_CONFIG[:provider]}" end end end这里的AliyunSmsAdapter只需要实现一个send方法并返回带状态、消息 ID 的 Hash。你可以把签名逻辑放到适配器内部,这样更换服务商时只需要换一个类。
3.3 验证码接口的防刷设计
短信验证码接口最容易被打爆。攻击者可以用大量手机号请求发送验证码,把你的短信额度消耗光,甚至导致真实用户无法接收到验证码。
防刷要分层做。第一层是图形验证码或者行为验证,用户必须通过验证后才能拿到短信验证码。第二层是频率限制,用 Rails.cache 实现:
def sms_send_allowed?(phone, ip) return false if Rails.cache.exist?("sms:code:#{phone}") daily_count = Rails.cache.read("sms:daily:#{ip}").to_i return false if daily_count >= 10 true end def mark_sms_sent(phone, ip) Rails.cache.write("sms:code:#{phone}", true, expires_in: 60.seconds) daily_count = Rails.cache.read("sms:daily:#{ip}").to_i + 1 Rails.cache.write("sms:daily:#{ip}", daily_count, expires_in: 24.hours) end第一个缓存 key 表示同一手机号 60 秒内不能重复发送;第二个 key 记录同一 IP 每天最多发送 10 次。生产环境缓存建议用 Redis,Rails.cache.increment可以原子自增,比先读后写更靠谱。
3.4 手机号校验和日志脱敏
任何进入短信系统的手机号,都建议先做格式校验。中国内地手机号正则很简单:
PHONE_RE = /\A1[3-9]\d{9}\z/但要注意国际号码、虚拟运营商号段的情况,根据业务放宽。日志记录时一定要脱敏,我在SmsLog里加了个方法:
def masked_phone phone.sub(/\A(\d{3})\d{4}(\d{4})\z/, '\1****\2') end避免数据泄露,也方便在讨论问题时直接贴日志。
3.5 失败告警:别等用户投诉才发现短信挂了
很多系统只有成功日志,没有失败告警。实际上短信服务商偶尔抽风,或者账号余额不足,都可能造成大量发送失败。我习惯在SmsService里加一个失败钩子:当一条短信失败达到阈值,就往企业微信或钉钉机器人发一条告警,把失败手机号、错误码、错误信息推送给值班人员。这样问题在用户发现前就被处理了。
4. 一块容易混淆的“热知识”:display: ruby 是什么意思
4.1 它是 CSS 属性,不是 Ruby 语言特性
搜索“ruby 短信接口”时经常会出现一个关联词:display: ruby 是什么意思。很多写代码的同事第一反应是“Ruby 里有个 display 方法?”,然后翻遍 Rails 文档找不到。真相是:这里的ruby是 CSS 中一种显示类型,和 Ruby 编程语言没有任何关系。
CSS 的display: ruby是用于布局东亚文字“注音符号”(ruby annotation)的。HTML 里用<ruby>标签包裹一段文本,再用<rt>子标签放注音,浏览器默认就会把注音显示在正文上方。例如:
<ruby>中文<rt>zhōng wén</rt></ruby>display: ruby就是给<ruby>元素用的默认盒模型类型。它还有一组配套属性,比如display: ruby-base、display: ruby-text,一般只有做排版引擎或富文本编辑器的人才会主动设置,普通业务开发几乎接触不到。
4.2 为什么搜 Ruby 短信时会撞见它
因为“ruby”这个词在计算机领域同时是编程语言名、CSS 属性名,还是文本排版术语。搜索引擎在匹配“ruby短信接口开发”的时候,会把所有包含 ruby 的页面都捞出来,自然就混进了 CSS 文档。如果你在 Rails 项目里也做过 CSS 样式调试,看到display: ruby时大概率一脸懵,以为自己写错了 Ruby 代码。
这种情况在开发中非常常见,各种技术名词撞车导致搜索成本上升。判断标准其实很简单:看它出现在什么文件里。如果是在.css文件或<style>标签里,后面跟着分号,那就是纯 CSS;如果是在.rb文件里,后面跟着的是方法调用或参数,那才跟 Ruby 语言有关。
4.3 遇到同名词时的判断方法
做短信集成时如果你去查“ruby”相关文档,建议先明确当前语境。“ruby”后面接“class”、“gem”、“Rails”那就是编程语言;“ruby”前面是display:那就是 CSS;再往深里说,日语里 ruby 还指红宝石和一种印刷字号。搜索引擎不理解你的语境,它只会做关键词匹配。自己心里有一根弦,遇到相似名词先看上下文,就不会浪费时间在错误文档里打转。
5. 常见问题与排查技巧实录
5.1 错误码对照速查
| 错误码 | 可能原因 | 处理方向 |
|---|---|---|
| isv.SMS_SIGNATURE_ILLEGAL | 签名不存在或未审核通过 | 检查SignName是否与后台一致 |
| isv.MOBILE_NUMBER_ILLEGAL | 手机号格式错误 | 检查号码是否为空号或加密号码 |
| isv.TEMPLATE_MISSING_PARAM | 模板参数缺失或不匹配 | 对比模板变量,检查TemplateParam的 JSON key |
| isv.BUSINESS_LIMIT_CONTROL | 触发服务商频率限制 | 等待冷却或调整发送频率策略 |
| 响应超时但实际发送成功 | 服务商返回慢,但运营商已接收 | 查看后台消息记录,不要盲目重发 |
| 验证码迟迟收不到 | 号码被运营商拦截、手机信号异常 | 检查日志状态,联系服务商查询状态报告 |
5.2 Rails 应用里常见的集成报错
除了服务商错误码,Rails 层本身也有几个高频问题。
Faraday::ConnectionFailed是网络问题,多半是服务器防火墙没有放行短信服务商域名端口。JSON::ParserError是服务商返回了非 JSON 内容,比如网关错误页,这时候要打印响应 body 而不是盲目解析。ActiveJob::DeserializationError通常是你在perform_later时传了 Active Record 对象而不是 ID,Job 反序列化失败。短信 Job 只传字符串和数字。
还有一个特别磨人的问题:签名总是校验失败。排查时先看签名原串是否包括所有参数,再看编码是否规范。很多排查半天最后发现是SignatureNonce里的加号被自动编码了。解决方法是签名用的字符串统一走percent_encode,保证前后一致。
5.3 回调重复通知的幂等处理
服务商为了保证回调可靠,会在你的接口没有返回 2xx 时反复推送。你的回调处理接口必须是幂等的。实现最简单的方法是:根据provider_message_id先查记录,如果已经处理过就立刻返回“成功”。更好一点的做法是给数据库加唯一索引,利用数据库去重:
class SmsCallbackController < ApplicationController skip_before_action :verify_authenticity_token def create SmsCallbackProcessor.call(callback_params) head :ok end end在SmsCallbackProcessor内部用find_or_create_by!(provider_message_id: ...)加事务保护,可以防止并发重复更新。
5.4 开发环境下不要真实发送
本地联调时如果每次都真实发短信,费钱不说,还容易被服务商限流。更好的办法是做一个 Fake 适配器,在非生产环境使用:
class FakeSmsAdapter def send(phone:, template_code:, template_params:) Rails.logger.info "[FAKE_SMS] phone=#{phone} code=#{template_params['code']}" { success: true, message_id: "fake-#{SecureRandom.uuid}" } end end然后把环境切换逻辑放在配置里:
def self.adapter return FakeSmsAdapter.new unless Rails.env.production? # ... 真实适配器 end这样开发时你可以在日志里看到验证码,方便联调;写测试时也能通过ActiveJob::TestHelper直接断言 Job 是否入队。
5.5 一次收不到短信的完整排查流程
用户来反馈“我收不到验证码”的时候,我习惯按这个顺序查:
- 先查
sms_logs表有没有这条发送记录,如果是 pending 或者根本没有记录,说明请求根本没发出去。 - 如果状态是 failed,看错误码和错误信息,按 5.1 表格定位。
- 如果状态是 success,但用户没收到,去服务商控制台看这条短信的状态报告,看是“发送成功”还是“运营商返回失败”。
- 如果运营商返回成功但用户没收到,大概率是手机终端拦截,让用户检查骚扰拦截、垃圾短信箱。
- 确认是不是号码在异常号段,比如空号或携号转网未正确处理。
这套流程能覆盖 90% 的情况。真正难查的是第 4 步,这时候只能靠经验和耐心。
前前后后接过多家短信服务商,最大的感受是:短信接口本身并不难,难的是把异常处理、防刷、回调、日志做成一套可靠链路。我见过不少项目最开始在 Controller 里同步调用服务商 SDK,某天早上短信服务商一抖,整个注册接口直接超时,用户疯狂投诉。后来改成异步加回调,问题立刻少了很多。如果让我给一个建议,那就是动手前先把发送日志表和适配器抽象出来,后面切换服务商、排查问题都会省力得多。另外别忘了把“display: ruby”这种热词搞清楚,别在一篇 CSS 文档里浪费半小时。