如何在 Ruby on Rails 商户网站中集成多银行在线支付
结论
在 Ruby on Rails 商户网站中接入多家银行支付时,通常没有必要逐一对接每家银行。更常见的做法是选择支持多家银行、网银或银行卡渠道的支付服务商,由服务商统一处理银行路由、收单、签名验证和交易查询。
只有当业务规模、费率或合规要求明确要求直连银行时,才适合为不同银行分别开发适配器。无论选择哪种方式,Rails 都应提供统一的支付接口,并通过支付订单、异步通知、主动查询和退款流程管理交易状态,避免银行之间的差异进入业务代码。
推荐的系统结构
支付集成可以分为三层:
- 订单层:管理商品订单和应付金额。
- 支付层:创建支付记录,维护支付状态和交易流水。
- 渠道适配层:封装不同支付服务商或银行的 API。
各渠道应向业务代码提供相近的调用方式,例如:
gateway.create_payment(payment)
gateway.query_payment(payment)
gateway.refund(payment, amount_cents:)
gateway.verify_callback(params, headers)
以后增加新银行时,只需添加对应的适配器,不必修改订单逻辑。
选择接入方式
通过聚合支付或支付网关接入
这种方式更适合大多数商户。商户只需对接一套 API。银行列表可以由服务商的支付页面展示,也可以由商户把用户选择的银行代码传给服务商。
一般流程如下:
- 商户与支付服务商签约并完成资质审核。
- 获取测试环境的商户号、密钥或证书。
- 在 Rails 中创建支付订单。
- 调用统一下单 API。
- 将用户重定向到收银台,或返回二维码、支付表单。
- 接收服务端异步通知。
- 验证签名,并核对金额、币种和商户订单号。
- 更新支付状态并执行发货。
- 定期查询交易并完成账务对账。
银行代码、签名算法和请求字段均由服务商定义。在查阅其接口文档之前,不应自行假设这些内容。
分别直连多家银行
直连银行时,每家银行使用的报文格式、证书、加密算法、回调规则和对账文件都可能不同。即使选择直连,也应使用统一接口隔离这些差异:
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 业务层不依赖某家银行的具体字段,后续增加或更换支付渠道时,通常只需修改适配层。
备注:内容仅供参考。
