PAYATHON 2026

如何在 Ruby on Rails 商户网站中集成多银行在线支付

支付小周

结论

在 Ruby on Rails 商户网站中接入多家银行支付时,通常没有必要逐一对接每家银行。更常见的做法是选择支持多家银行、网银或银行卡渠道的支付服务商,由服务商统一处理银行路由、收单、签名验证和交易查询。

只有当业务规模、费率或合规要求明确要求直连银行时,才适合为不同银行分别开发适配器。无论选择哪种方式,Rails 都应提供统一的支付接口,并通过支付订单、异步通知、主动查询和退款流程管理交易状态,避免银行之间的差异进入业务代码。

推荐的系统结构

支付集成可以分为三层:

  1. 订单层:管理商品订单和应付金额。
  2. 支付层:创建支付记录,维护支付状态和交易流水。
  3. 渠道适配层:封装不同支付服务商或银行的 API。

各渠道应向业务代码提供相近的调用方式,例如:

gateway.create_payment(payment)
gateway.query_payment(payment)
gateway.refund(payment, amount_cents:)
gateway.verify_callback(params, headers)

以后增加新银行时,只需添加对应的适配器,不必修改订单逻辑。

选择接入方式

通过聚合支付或支付网关接入

这种方式更适合大多数商户。商户只需对接一套 API。银行列表可以由服务商的支付页面展示,也可以由商户把用户选择的银行代码传给服务商。

一般流程如下:

  1. 商户与支付服务商签约并完成资质审核。
  2. 获取测试环境的商户号、密钥或证书。
  3. 在 Rails 中创建支付订单。
  4. 调用统一下单 API。
  5. 将用户重定向到收银台,或返回二维码、支付表单。
  6. 接收服务端异步通知。
  7. 验证签名,并核对金额、币种和商户订单号。
  8. 更新支付状态并执行发货。
  9. 定期查询交易并完成账务对账。

银行代码、签名算法和请求字段均由服务商定义。在查阅其接口文档之前,不应自行假设这些内容。

分别直连多家银行

直连银行时,每家银行使用的报文格式、证书、加密算法、回调规则和对账文件都可能不同。即使选择直连,也应使用统一接口隔离这些差异:

module Payments
  class Gateway
    def create_payment(payment)
      raise NotImplementedError
    end

    def query_payment(payment)
      raise NotImplementedError
    end

    def refund(payment, amount_cents:)
      raise NotImplementedError
    end

    def verify_callback(payload:, headers:)
      raise NotImplementedError
    end
  end
end

再由不同银行分别实现该接口:

module Payments
  class BankAGateway < Gateway
    def create_payment(payment)
      # 按 Bank A 的官方文档构造请求、签名并发送
    end

    def verify_callback(payload:, headers:)
      # 按 Bank A 的签名规则验证通知
    end
  end
end

渠道选择可以统一放在工厂类中:

module Payments
  class GatewayFactory
    def self.build(provider)
      case provider.to_s
      when "bank_a"
        BankAGateway.new
      when "bank_b"
        BankBGateway.new
      else
        raise ArgumentError, "Unsupported payment provider"
      end
    end
  end
end

建立支付数据模型

不要只在商品订单中保存一个 paid 布尔值。一笔订单可能有多次支付尝试,也可能经历失败、关闭或退款,因此需要单独保存支付记录。

class CreatePayments < ActiveRecord::Migration[7.1]
  def change
    create_table :payments do |t|
      t.references :order, null: false, foreign_key: true
      t.string :merchant_reference, null: false
      t.string :provider, null: false
      t.string :provider_transaction_id
      t.string :status, null: false, default: "pending"
      t.integer :amount_cents, null: false
      t.string :currency, null: false
      t.json :metadata, null: false, default: {}
      t.datetime :paid_at

      t.timestamps
    end

    add_index :payments, :merchant_reference, unique: true
    add_index :payments,
              [:provider, :provider_transaction_id],
              unique: true,
              where: "provider_transaction_id IS NOT NULL"
  end
end

示例中的 Rails 迁移版本需要换成项目实际使用的版本。不能根据这个版本推断银行 SDK 或支付 API 的兼容范围。

金额应以最小货币单位的整数保存。例如,人民币 10.50 元可以保存为 1050 分,以免出现浮点数误差。

class Payment < ApplicationRecord
  belongs_to :order

  enum :status, {
    pending: "pending",
    processing: "processing",
    succeeded: "succeeded",
    failed: "failed",
    closed: "closed",
    refunded: "refunded"
  }

  validates :merchant_reference, uniqueness: true
  validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
end

创建支付订单

路由可以设置两个入口,分别用于创建支付和接收通知:

Rails.application.routes.draw do
  resources :orders, only: [] do
    resources :payments, only: :create
  end

  post "/payment_callbacks/:provider",
       to: "payment_callbacks#create",
       as: :payment_callback
end

创建支付时,必须从服务端订单读取金额,不能采用浏览器提交的金额:

class PaymentsController < ApplicationController
  def create
    order = current_user.orders.find(params[:order_id])
    provider = params.require(:provider)

    payment = order.payments.create!(
      merchant_reference: SecureRandom.uuid,
      provider: provider,
      amount_cents: order.payable_amount_cents,
      currency: order.currency,
      status: "pending"
    )

    gateway = Payments::GatewayFactory.build(provider)
    result = gateway.create_payment(payment)

    payment.update!(
      provider_transaction_id: result.fetch(:transaction_id),
      status: "processing"
    )

    redirect_to result.fetch(:payment_url), allow_other_host: true
  rescue Payments::GatewayError => e
    render json: { error: e.message }, status: :bad_gateway
  end
end

payment_url、transaction_id 等字段只是适配层约定的统一返回结构。调用真实接口时,需要把服务商的响应转换为这个结构。

正确处理异步通知

用户完成支付后的浏览器跳转只能用于改善体验,不能作为支付成功的依据。用户可能直接关闭页面,跳转请求也可能被伪造。订单状态应以通过验签的服务端通知或主动查询结果为准。

class PaymentCallbacksController < ActionController::API
  def create
    gateway = Payments::GatewayFactory.build(params[:provider])

    event = gateway.verify_callback(
      payload: request.raw_post,
      headers: request.headers
    )

    payment = Payment.find_by!(
      merchant_reference: event.fetch(:merchant_reference)
    )

    Payment.transaction do
      payment.lock!

      verify_payment!(payment, event)

      unless payment.succeeded?
        payment.update!(
          status: "succeeded",
          provider_transaction_id: event.fetch(:transaction_id),
          paid_at: Time.current
        )

        FulfillOrderJob.perform_later(payment.order_id)
      end
    end

    render plain: gateway.success_response
  rescue Payments::InvalidSignature
    head :unauthorized
  rescue ActiveRecord::RecordNotFound, PaymentMismatch
    head :unprocessable_entity
  end

  private

  def verify_payment!(payment, event)
    valid =
      payment.amount_cents == event.fetch(:amount_cents) &&
      payment.currency == event.fetch(:currency) &&
      payment.provider == params[:provider]

    raise PaymentMismatch unless valid
  end
end

回调接口必须支持幂等处理。银行可能重复发送同一条通知,如果每次收到通知都执行发货,就会产生重复操作。支付状态更新、库存处理和业务事件需要通过事务、唯一约束或幂等任务加以保护。

补充主动查询与对账

异步通知可能延迟,也可能丢失。对于长时间处于 processing 状态的支付,可以通过后台任务调用交易查询接口:

class ReconcilePaymentJob < ApplicationJob
  queue_as :default

  def perform(payment_id)
    payment = Payment.find(payment_id)
    return if payment.succeeded? || payment.refunded?

    gateway = Payments::GatewayFactory.build(payment.provider)
    result = gateway.query_payment(payment)

    # 再次核对订单号、金额、币种及交易状态后更新记录
  end
end

还应根据支付服务商或银行提供的账单文件进行每日对账,检查以下异常:

  • 银行显示成功,但商户系统仍为处理中。
  • 商户系统显示成功,但账单中没有对应交易。
  • 支付金额或币种不一致。
  • 退款状态与退款金额不一致。
  • 同一银行流水号关联了多条本地记录。

安全与上线注意事项

  • 密钥和证书应保存在 Rails encrypted credentials、密钥管理服务或受保护的环境变量中,不能提交到代码仓库。
  • 必须按照银行文档验证签名、证书链、时间戳和随机数,不要自行设计加密流程。
  • 所有支付请求、回调和查询都应使用 HTTPS。
  • 日志中不要记录银行卡号、验证码、私钥、完整签名材料或其他敏感数据。
  • 不要直接处理银行卡安全码等数据。优先使用银行或支付服务商托管的收银台,减少需要纳入安全合规管理的范围。
  • 为创建支付、退款等操作设置幂等键,防止网络重试产生重复交易。
  • 回调地址通常需要开放公网访问,但身份验证必须依靠签名,不能只检查来源 IP。
  • 支付成功后的发货、权益开通等操作可以交给后台任务处理,同时要保证任务能够重试且不会重复执行。
  • 上线前需要测试支付成功、失败、取消、超时、重复通知、金额不符、签名错误、退款和通知丢失等场景。
  • 是否需要满足 PCI DSS、当地支付监管、数据留存或强客户认证要求,取决于业务所在地、支付方式,以及商户是否接触银行卡数据。这些要求应由支付服务商和合规人员确认。

多银行支付的重点不在于给页面添加多个银行按钮,而在于建立统一、可验证、可重试、可对账的支付状态机。只要 Rails 业务层不依赖某家银行的具体字段,后续增加或更换支付渠道时,通常只需修改适配层。

备注:内容仅供参考。