PAYATHON 2026

Spree 自定义支付出现 unknown attribute: server

支付老李

结论

unknown attribute: server 并不表示订单表或支付表缺少字段,也不该通过迁移添加 server 列来解决。

异常出现在 Spree::Gateway#provider 创建支付服务实例时。Spree 2.2 会把 Gateway 的配置项传给 gateway_class.new(...),参数通常包括 server、test、币种和密钥。当前自定义 Gateway 返回的类很可能是 Spree::LiqpayCheckout 一类的 ActiveRecord 模型。Rails 会把参数哈希当作模型属性批量赋值,但该模型没有 server 字段,因此抛出:

ActiveRecord::UnknownAttributeError: unknown attribute: server

另一个问题是,回调直接执行了:

@order.update_attribute :payment_state, 'paid'

这并没有真正完成 Spree 的 Payment。订单进入最终状态时,Spree 仍会发现一笔尚未完成的支付,于是继续调用 purchase!。这个过程会实例化 provider,最终触发上述异常。

正确的处理方式是:

  1. 让 gateway_class 返回支付适配器,而不是 ActiveRecord 支付来源模型。
  2. 在 LiqPay webhook 中校验签名,并通过 Payment 状态机完成支付。
  3. 不要直接写入 order.payment_state。
  4. 分开处理浏览器返回地址和 LiqPay 服务端 webhook。
  5. 让 webhook 支持幂等调用,避免重复创建 Payment 或重复完成订单。

server 从哪里来

在 Spree 2.2 的 Gateway 体系中,Spree::Gateway#provider 大致会执行:

gateway_class.new(options)

这里的 options 来自支付方式的 preferences。对于继承自 Spree::Gateway 的支付方式,Spree 会加入测试环境和生产环境相关的配置,例如:

{
  server: :test,
  test: true,
  public_key: "...",
  private_key: "..."
}

这些参数本来应该传给类似 ActiveMerchant Gateway 的普通 Ruby 对象:

class LiqpayProvider
  def initialize(options = {})
    @options = options
  end
end

如果 gateway_class 错误地返回了 ActiveRecord 模型:

def gateway_class
  Spree::LiqpayCheckout
end

实际执行的代码就变成了:

Spree::LiqpayCheckout.new(server: :test, ...)

ActiveRecord 会把 server 视为数据库属性。模型中没有这个字段,所以报出 unknown attribute: server。

首先应检查自定义 Gateway 中的 gateway_class、provider_class 或类似方法。项目实际使用的方法名,要以 Spree 2.2.4 中 Spree::Gateway 的实现为准。

不要混用 Payment Source 和 Gateway Provider

这两个对象的职责并不相同:

  • Payment Source 保存某笔支付需要的数据,例如 LiqPay transaction ID、token 或外部订单号。
  • Gateway Provider 负责与支付平台交互,实现 Spree 调用的 purchase、authorize、capture、void、credit 等接口。
  • Spree::Payment 关联订单、支付方式和 source,并通过状态机记录支付结果。

source 可以是 ActiveRecord 模型:

class Spree::LiqpayCheckout < ActiveRecord::Base
  has_many :payments,
           as: :source,
           class_name: 'Spree::Payment'

  attr_accessible :transaction_id, :status if defined?(ProtectedAttributes)
end

provider 则应当是普通 Ruby 对象:

class LiqpayProvider
  def initialize(options = {})
    @public_key  = options[:public_key]
    @private_key = options[:private_key]
    @server      = options[:server]
  end
end

gateway_class 不应返回 Spree::LiqpayCheckout。

自定义 Gateway 的基本结构

如果继续使用 Spree::Gateway,可以按照下面的方式拆分。示例只展示 Spree 所需的结构。LiqPay 的请求参数、签名算法和接口地址,应按项目当前使用的 LiqPay API 版本实现。

module Spree
  class Gateway::Liqpay < Gateway
    preference :public_key, :string
    preference :private_key, :string

    def gateway_class
      ::LiqpayProvider
    end

    def payment_source_class
      Spree::LiqpayCheckout
    end
  end
end

Provider 接收 Spree 传入的配置,并返回 ActiveMerchant 风格的响应:

class LiqpayProvider
  def initialize(options = {})
    @public_key  = options[:public_key]
    @private_key = options[:private_key]
    @server      = options[:server]
    @test        = options[:test]
  end

  def purchase(amount, source, gateway_options = {})
    # 如果这里执行同步扣款,应调用 LiqPay API,
    # 并用真实结果构造 Response。
    ActiveMerchant::Billing::Response.new(
      true,
      'LiqPay payment accepted',
      { transaction_id: source.transaction_id },
      authorization: source.transaction_id
    )
  end
end

不能为了避开异常,就让 purchase 无条件返回成功。只有可信 webhook 已经确认付款,或者该方法确实调用 LiqPay 并成功完成扣款时,才能返回成功响应。

如果 LiqPay 的完整流程是跳转到站外支付,再由 webhook 通知结果,它就不适合直接套用 Spree 的同步 purchase 模型。这种情况下,可以实现专用的异步 PaymentMethod,或者让 purchase 只确认已经验证过的外部交易。具体要覆盖哪些方法,取决于 Spree 2.2.4 的 Payment Processing 实现以及项目对订单状态机的定制。

为什么不能直接修改 payment_state

order.payment_state 是根据关联 Payment 的状态和金额得出的订单支付汇总,不是支付成功的事实来源。

当前代码只创建了一笔 Payment:

@order.payments.create!(
  source: Spree::LiqpayCheckout.create,
  amount: @order.total,
  payment_method: ...
)

但这笔 Payment 并没有转为 completed,可能仍处于 checkout、processing 或 pending 状态。随后,代码又手动把订单写成 paid:

@order.update_attribute :payment_state, 'paid'

这会造成数据不一致:

Order.payment_state = "paid"
Payment.state       = "checkout" 或 "pending"

订单下次更新时,Spree 会根据 Payment 重新计算支付状态,手动写入的 paid 可能被覆盖。完成订单时,Spree 也会尝试处理这笔尚未完成的 Payment,因此调用 purchase!。

正确做法是改变 Payment 的状态,再由 Spree 更新订单的 payment_state。

更合理的 webhook 处理方式

下面的示例适用于 Spree 2.2 的处理思路。不同小版本中的 Payment 状态机事件名称可能不同,应以项目里的 Spree::Payment 定义为准。

def liqpay_api_callback
  payload = verify_and_decode_liqpay_callback!

  order = Spree::Order.find_by!(number: payload.fetch('order_id'))
  payment_method = Spree::PaymentMethod.find_by!(
    type: 'Spree::Gateway::Liqpay',
    active: true
  )

  Spree::Order.transaction do
    order.lock!

    transaction_id = payload.fetch('transaction_id')

    source = Spree::LiqpayCheckout.find_or_create_by!(
      transaction_id: transaction_id
    )

    payment = order.payments.find_or_initialize_by(
      payment_method: payment_method,
      source: source
    )

    if payment.new_record?
      payment.amount = verified_amount(payload, order)
      payment.save!
    end

    case payload.fetch('status')
    when 'success', 'sandbox'
      complete_payment!(payment, transaction_id)
    when 'failure', 'error', 'reversed'
      fail_payment!(payment)
    else
      # pending、processing 等状态只记录,不宣告支付成功
    end
  end

  head :ok
rescue ActiveRecord::RecordNotFound, KeyError
  head :unprocessable_entity
rescue InvalidLiqpaySignature
  head :unauthorized
rescue => e
  logger.error("#{e.class}: #{e.message}")
  logger.error(e.backtrace.join("\n"))
  head :internal_server_error
end

Payment 的状态转换可以集中封装:

def complete_payment!(payment, transaction_id)
  return if payment.completed?

  payment.update_column(:response_code, transaction_id)

  payment.started_processing! if payment.respond_to?(:checkout?) &&
                                 payment.checkout?

  payment.complete!
end

def fail_payment!(payment)
  return if payment.failed? || payment.completed?

  payment.failure!
end

这段代码有几个与版本相关的前提:

  • Spree 2.2 的 Payment 通常提供 started_processing!、complete! 和 failure! 等状态机事件,但仍要检查项目实际安装的 Spree 代码。
  • 如果 Payment 无法从 checkout 直接执行 complete!,需要先转为 processing。
  • Payment 的状态转换通常会触发订单更新。如果自定义代码绕过了相关回调,应调用该版本对应的订单 updater,而不是直接写入 payment_state。
  • response_code 可以保存 LiqPay transaction ID,但这个字段的具体用途还要结合现有支付扩展确认。

webhook 必须验证,不能直接相信 params[:status]

下面的判断并不安全:

if params[:status] == 'success'

任何能访问该 URL 的请求方都可能伪造 status=success。服务端至少要验证:

  • LiqPay callback 的签名。
  • 外部交易 ID。
  • 商户账号或 public key。
  • 订单标识。
  • 支付金额。
  • 币种。
  • 交易状态。
  • 该交易是否已经处理过。

不能直接采用回调参数中的金额,还应将它与订单应付金额比较。比较货币金额时,要遵循 LiqPay 的最小单位和精度规则,避免直接比较浮点数。

订单查询最好使用无法猜测或受签名保护的标识。如果 LiqPay 的 order_id 使用 Spree 订单号,还要确认订单属于当前商户,并且金额一致。

webhook 必须支持重复调用

支付平台可能因为超时、网络错误,或没有收到 2xx 响应而重复发送通知。下面的写法可能创建重复的 Payment:

if @order.payments.length == 0
  @order.payments.create!(...)
end

它还存在并发竞争。两个 webhook 请求可能同时发现 Payment 数量为零,然后各自创建一笔记录。

更稳妥的做法是以 LiqPay transaction ID 建立唯一性约束,并在事务中锁定订单:

order.with_lock do
  source = Spree::LiqpayCheckout.find_or_create_by!(
    transaction_id: transaction_id
  )

  payment = order.payments.find_or_create_by!(
    payment_method: payment_method,
    source: source
  ) do |record|
    record.amount = order.total
  end

  payment.complete! unless payment.completed?
end

数据库也应为 transaction_id 添加唯一索引。仅依靠应用层的 find_or_create_by!,无法彻底避免并发产生的重复记录。

浏览器返回地址不能代替 webhook

payment_network_callback 看起来是用户完成支付后,浏览器跳回商店的入口:

def payment_network_callback
  if @order.paid?
    @order.next
  end

  redirect_to checkout_state_path(@order.state)
end

这个地址不能作为确认支付成功的依据,原因包括:

  • 用户可能关闭页面,导致该接口一直没有被访问。
  • 用户可能重复刷新。
  • 浏览器跳转可能早于 webhook。
  • 返回地址中的参数可能遭到篡改。
  • 支付平台的服务端通知才是权威结果。

浏览器返回接口只需要查询当前状态并跳转:

def payment_network_callback
  @order = Spree::Order.find_by!(number: params[:order_id])

  if @order.paid?
    redirect_to order_path(@order)
  else
    redirect_to checkout_state_path(@order.state),
                notice: '支付结果正在确认,请稍后刷新'
  end
end

是否在 webhook 中推进订单状态,要看结账状态机如何设计。如果订单停在 confirm,而且 Payment 已经真正完成,可以通过订单状态机事件完成订单,不能用 update_column 直接修改 state。

确认当前状态允许转换后,可以执行:

order.next! if order.paid? && order.state == 'confirm'

Spree 2.2 使用的 state_machine 版本也可能只提供 next,没有 next!,应以项目中的订单模型为准。同时要注意,如果这次转换会再次调用 process_payments!,自定义异步支付方式必须保证已完成的 Payment 不会再次执行 purchase。

建议的排查顺序

  1. 打开自定义 Spree::Gateway::Liqpay,检查 gateway_class 的返回值。
  2. 确认它没有返回 Spree::LiqpayCheckout 或其他 ActiveRecord 模型。
  3. 检查 Spree::Gateway#provider 传给该类的 options,确认其中包含 server。
  4. 将 provider 和 payment source 拆成两个类。
  5. 删除直接修改 order.payment_state 的代码。
  6. 在完成签名、金额和币种校验后,通过 webhook 推进 Payment 状态。
  7. 使用 transaction ID 实现幂等,并添加数据库唯一索引。
  8. 确认订单完成时,不会对 LiqPay 已经完成的交易再次扣款。
  9. 浏览器返回接口只负责查询和展示,不承担支付确认。

这个异常表面上是 server 参数与类不匹配,实际原因是 Gateway Provider、Payment Source 和异步支付状态的职责混在了一起。把这些职责分开,并以 Payment 状态机作为支付结果的事实来源,就能同时解决 provider 初始化失败,以及订单显示已支付却仍然调用 purchase 的问题。

备注:内容仅供参考。