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,最终触发上述异常。
正确的处理方式是:
- 让
gateway_class返回支付适配器,而不是 ActiveRecord 支付来源模型。 - 在 LiqPay webhook 中校验签名,并通过 Payment 状态机完成支付。
- 不要直接写入
order.payment_state。 - 分开处理浏览器返回地址和 LiqPay 服务端 webhook。
- 让 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。
建议的排查顺序
- 打开自定义
Spree::Gateway::Liqpay,检查gateway_class的返回值。 - 确认它没有返回
Spree::LiqpayCheckout或其他 ActiveRecord 模型。 - 检查
Spree::Gateway#provider传给该类的 options,确认其中包含server。 - 将 provider 和 payment source 拆成两个类。
- 删除直接修改
order.payment_state的代码。 - 在完成签名、金额和币种校验后,通过 webhook 推进 Payment 状态。
- 使用 transaction ID 实现幂等,并添加数据库唯一索引。
- 确认订单完成时,不会对 LiqPay 已经完成的交易再次扣款。
- 浏览器返回接口只负责查询和展示,不承担支付确认。
这个异常表面上是 server 参数与类不匹配,实际原因是 Gateway Provider、Payment Source 和异步支付状态的职责混在了一起。把这些职责分开,并以 Payment 状态机作为支付结果的事实来源,就能同时解决 provider 初始化失败,以及订单显示已支付却仍然调用 purchase 的问题。
备注:内容仅供参考。
