Rails 使用云片和 China sms 发送验证信息

rails 6,云片,china_sms,需要实现短信验证和语音验证。

1.添加 china_sms 的 gem 包:

# China SMS client
gem 'china_sms', github: 'saberma/china_sms', branch: 'master'

然后运行:

bundle install

2.配置

在 'config/initializers/china_sms.rb' 文件中添加一行配置:

ChinaSMS.use :yunpian, password: Figaro.env.YUNPIAN_API_KEY

在 'config/initializers/inflections.rb' 文件中添加:

inflect.acronym 'SMS'

在 'config/application.yml.example' 文件中添加在云片注册的 key 和 sign

# 云片
YUNPIAN_API_KEY: ''
YUNPIAN_SIGN: ''

还需要在 config 下手动新建 yunpian_blocklist.txt 和 yunpian_blockmobile.txt 文件,他们是关键词、关键字和手机号黑名单

3.创建模型

我们需要个模型来存储验证码、手机号、验证次数等等信息,模型如下,

create_table :verification_codes do |t|
  t.string :mobile, index: true
  t.string :code
  t.datetime :expired_at
  t.integer :failed_attempts, default: 0

  t.timestamps
end

verification_code.rb:

class VerificationCode < ApplicationRecord
  # 过期时间 : 不得超过 5 分钟
  EXPIRED_DURATION = 5.minutes

  # 尝试激活次数 : 不得超过 5 次
  MAXIMUM_ATTEMPTS = 5

mobile: true 请点这里

  # mobile 不能为空,以及验证格式等
  validates :mobile, presence: true, mobile: true
  validates :code, presence: true
  validates :expired_at, presence: true

  before_validation :setup_mobile, on: :create
  before_validation :setup_code, on: :create
  before_validation :setup_expired_at, on: :create

  scope :expired, -> { where('? > expired_at', Time.current) }

  # 在重新发送验证码时,删除之前该手机号所有已经过期数据并重新创建
  def self.regenerate_verification_code_for_mobile!(mobile)
    transaction do
      where(mobile: mobile).expired.destroy_all
      create!(mobile: mobile)
    end
  end

  # 验证手机号、验证码是否通过
  def self.authenticate(mobile, unauthenticated_code)
    where(mobile: mobile).each do |record|
      # 调用 __send__ 调用私有方法 authenticate(unauthenticated_code) 判断验证信息是否通过
      return true if record.__send__(:authenticate, unauthenticated_code)
    end; 
    false
  end

  private

  # 
  def authenticate(unauthenticated_code)
    # 过期或者验证次数是否超出
    return false if expired? || attempts_exceeded?
    # 验证码是否正确
    return valid_code?(unauthenticated_code)
  end

  # 是否过期
  def expired?
    expired_at < Time.current
  end

  # 验证次数是否超出
  def attempts_exceeded?
    failed_attempts >= MAXIMUM_ATTEMPTS
  end

  # 验证码是否正确
  def valid_code?(unauthenticated_code)
    if code == unauthenticated_code
      true
    else
      increment!(:failed_attempts)
      false
    end
  end

  # 去除前后空格
  def setup_mobile
    self.mobile = mobile.to_s.strip
  end

  # 获取0-9999随机4位数验证码
  def setup_code
    self.code = rand(0..9999).to_s.rjust(4, '0')
  end

  # 过期时间为当前时间 + 5 分钟
  def setup_expired_at
    self.expired_at = Time.current + EXPIRED_DURATION
  end
end

4.编写接口

在 app/libs 文件夹下新建 external_api.rb 文件,内容为:

module ExternalAPI
  def self.sms

  end

  def self.voice

  end
end

然后在 libs 文件夹下新建 external_api 文件夹,在新建的 external_api 文件夹下新建 sms.rb、voice.rb,分别是发送短信验证、推送语音验证

sms.rb:

module ExternalAPI
  class SMS
    # 通过错误码向 controller 抛出错误信息
    Error = Class.new(StandardError)
    KeywordViolationError = Class.new(Error)
    RateLimitExceededError = Class.new(Error)
    TemplateLimitExceededError = Class.new(Error)

    # 黑名单匹配
    BLOCKLIST = File.read(Rails.root.join('config/yunpian_blocklist.txt')).lines.map(&:strip)
    BLOCKMOBILE = File.read(Rails.root.join('config/yunpian_blockmobile.txt')).lines.map(&:strip)

    def deliver(mobile, message)
      
      # 黑名单匹配
      message = message.tr('【', '[').tr('】', ']')
      BLOCKLIST.each { |word| message.gsub!(word, '*' * word.size) }
      if BLOCKMOBILE.include?(mobile)
        Rails.logger.info "[ExternalAPI::SMS.deliver] blocked mobile: #{mobile}"
        return
      end

      # 格式化短信
      sms = format('【%s】%s', Figaro.env.YUNPIAN_SIGN, message)

      # 发送短信
      r = ChinaSMS.to(mobile, sms)

      # 判断返回码
      if r.key?('code')
        case r['code']
        when 0 then return
        when 4 then raise KeywordViolationError, r['detail']
        when 8 then raise RateLimitExceededError, r['detail']
        when 33 then raise TemplateLimitExceededError, r['detail']
        else raise Error, r['detail']
        end
      else
        case r['data'][0]['code']
        when 0 then return
        when 4 then raise KeywordViolationError, r['data'][0]['msg']
        when 8 then raise RateLimitExceededError, r['data'][0]['msg']
        when 33 then raise TemplateLimitExceededError, r['data'][0]['msg']
        else raise Error, r['data'][0]['msg']
        end
      end
    end

    # 异步处理
    def deliver_async(mobile, message, at: nil)
    end
  end
end

voice.rb

module ExternalAPI
  class Voice
    Error = Class.new(StandardError)
    RateLimitExceededError = Class.new(Error)
    TemplateLimitExceededError = Class.new(Error)

    def deliver(mobile, code)
      r = ChinaSMS.voice_to(mobile, code)
      if r.key?('code')
        case r['code']
        when 0 then return
        when 8 then raise RateLimitExceededError, r['detail']
        when 33 then raise TemplateLimitExceededError, r['detail']
        else raise Error, r['detail']
        end
      else
        raise r['msg'].presence || '语音验证码推送失败' unless r['count'].to_i.positive?
      end
    end

    # 异步处理
    def deliver_async(mobile, code, at: nil)
    end
  end
end

发送短信嘛,肯定是要异步发送,不能堵塞,所以我们在 workers 文件夹下新建 external_api 文件夹,其中有两个文件,分别对应发送短信和推送语音验证。

sms_deliver_worker.rb:

module ExternalAPI
  class SMSDeliverWorker
    include Sidekiq::Worker
    sidekiq_options retry: false

    def perform(mobile, message)
      ExternalAPI.sms.deliver(mobile, message)
    end
  end
end

voice_deliver_worker.rb:

module ExternalAPI
  class VoiceDeliverWorker
    include Sidekiq::Worker
    sidekiq_options retry: false

    def perform(mobile, code)
      ExternalAPI.voice.deliver(mobile, code)
    end
  end
end

写好之后我们还需要在 sms 和 voice 的 API 对应的 deliver_async 方法添加代码:

def deliver_async(mobile, message, at: nil)
  SMSDeliverWorker.perform_at(at || Time.current, mobile, message)
end
def deliver_async(mobile, message, at: nil)
  VoiceDeliverWorker.perform_at(at || Time.current, mobile, code)
end

接口就已经写好了,我们再新建 Controller,来响应前端发送短信/推送语音的请求。

5.编写 Controller

需要两个 Controller 来处理短信验证和语音验证请求,我们分别命名为:verification_codes_controller、voice_codes_controller,

render_ajax_success、render_ajax_failure 点这里

verification_codes_controller.rb:

class VerificationCodesController < ApplicationController
  skip_before_action :authenticate_user!, only: [:create]

  def create
    verification_code = VerificationCode.regenerate_verification_code_for_mobile!(verification_code_params[:mobile])

    # 这里是为了方便在开发时做测试,不需要浪费云片资源,
    if Figaro.env.OMNIAUTH_ALLOW_DEVELOPER_STRATEGY.present?
      render_ajax_success(developer_notice: format('模拟登录已开启,您的验证码是%s', verification_code.code))
    else
      #     # 通过 ExternalAPI.sms.deliver 调用并传参就好了
      ExternalAPI.sms.deliver(verification_code.mobile, format('您的验证码是%s', verification_code.code))
      render_ajax_success
    end
  # 这里通过在 ExternalAPI 中已经定义好的获取返回码的错误信息来进行处理
  rescue ExternalAPI::SMS::RateLimitExceededError
    render_ajax_failure(message: '操作过于频繁,请稍后再试')
  rescue ExternalAPI::SMS::TemplateLimitExceededError
    render_ajax_failure(message: '操作过于频繁,请稍后再试')
  rescue ActiveRecord::RecordInvalid => e
    return render_ajax_failure(message: '手机号不能为空') if e.record.errors.added?(:mobile, :blank)
    return render_ajax_failure(message: e.record.errors[:mobile]) if e.record.errors.key?(:mobile)
    raise
  end

  private

  def verification_code_params
    params.require(:verification_code).permit(:mobile)
  end
end

voice_codes_controller.rb:

class VoiceCodesController < ApplicationController
  skip_before_action :authenticate_user!, only: [:create]

  def create
    verification_code = VerificationCode.regenerate_verification_code_for_mobile!(voice_code_params[:mobile])
    # 通过 ExternalAPI.voice.deliver 调用并传参就好了
    ExternalAPI.voice.deliver(verification_code.mobile, verification_code.code)
    render_ajax_success
  rescue ExternalAPI::Voice::RateLimitExceededError
    render_ajax_failure(message: '操作过于频繁,请稍后再试')
  rescue ExternalAPI::Voice::TemplateLimitExceededError
    render_ajax_failure(message: '操作过于频繁,请稍后再试')
  rescue ActiveRecord::RecordInvalid => e
    return render_ajax_failure(message: '手机号不能为空') if e.record.errors.added?(:mobile, :blank)
    return render_ajax_failure(message: e.record.errors[:mobile]) if e.record.errors.key?(:mobile)
    raise
  end

  private

  def voice_code_params
    params.require(:voice_code).permit(:mobile)
  end
end

到这里就是所有后台的代码了,前端的处理如倒计时、表单、显示错误信息等代码点这里

posted @ 2020-07-04 23:50  Mr-Ran  阅读(411)  评论(0编辑  收藏  举报