Ruby AASM 支付处理中如何结合 before callback 与 guard
结论
不建议把“在 before callback 中调用支付 API,再通过 attr_accessor 把结果传给 guard”作为主要方案。
这种写法在某些 AASM 配置和调用路径下能够运行,但它把不同职责混在了一起,遇到重复调用、并发、进程重启或数据库保存失败时,很容易出问题。更可靠的设计是:
- AASM 只描述支付状态和合法的状态转换。
- 由 service object 调用外部支付 API。
- 根据明确的 API 结果触发“支付成功”或“支付失败”事件。
- 增加
processing状态,并记录支付请求、响应和远端交易号。 - 通过幂等键、数据库锁和对账机制处理重复请求及网络不确定性。
如果业务很简单,也可以在 before callback 中调用 API,但失败时应显式抛出异常。只返回 false,通常无法可靠地阻止后续状态转换。
为什么不宜让 guard 承担支付调用
guard 适合判断一个没有副作用的条件,例如:
def refundable?
paid? && remote_transaction_id.present?
end
它不适合执行扣款、发送消息或创建远端订单。guard 不一定只在事件真正执行时运行,查询事件能否执行也可能触发它,例如:
payment.may_pay?
如果 guard 内部调用了支付 API,一次普通的状态检查就可能造成真实扣款。
把 API 调用放进 before callback,再用 attr_accessor 临时保存结果,虽然能避免 guard 直接产生副作用,但仍然存在以下问题:
- 临时属性不会持久化,进程退出后结果就会丢失。
- 重试事件时可能再次调用支付 API。
- API 已经成功,但数据库状态保存失败时,本地仍会显示未支付。
- 多个进程同时处理同一笔支付时,可能发生重复扣款。
- callback 的执行顺序属于实现细节。升级 AASM 后,需要重新核对当前版本的文档并运行测试。
所以,guard 应当检查已经存在的事实,而不是负责产生这个事实。
推荐设计:先进入处理中,再根据结果完成转换
状态机可以明确表示支付正在处理:
class Payment
include AASM
aasm column: :state do
state :created, initial: true
state :processing
state :paid
state :failed
state :refunded
event :start_processing do
transitions from: :created, to: :processing
end
event :mark_paid do
transitions(
from: :processing,
to: :paid,
guard: :confirmed_by_provider?
)
end
event :mark_failed do
transitions from: :processing, to: :failed
end
end
private
def confirmed_by_provider?
provider_status == "succeeded" &&
remote_transaction_id.to_s != ""
end
end
其中,provider_status 和 remote_transaction_id 应该是持久化字段,而不是 attr_accessor。guard 检查的是已经保存的支付凭据。
外部 API 调用则放在独立的服务对象中:
class ProcessPayment
def initialize(payment, gateway:)
@payment = payment
@gateway = gateway
end
def call
payment.with_lock do
return payment if payment.paid?
payment.start_processing! if payment.created?
end
response = gateway.charge(
amount: payment.amount,
idempotency_key: payment.idempotency_key
)
payment.with_lock do
payment.provider_status = response.status
payment.remote_transaction_id = response.transaction_id
payment.provider_response = response.raw_data
if response.success?
payment.mark_paid!
else
payment.save!
payment.mark_failed!
end
end
payment
rescue PaymentGateway::TimeoutError => error
record_uncertain_result(error)
raise
end
private
attr_reader :payment, :gateway
def record_uncertain_result(error)
payment.update!(
provider_status: "unknown",
last_error: error.message
)
end
end
实际项目需要根据支付平台调整字段名称和 gateway 返回对象。这段代码想表达的是,API 返回成功,不代表本地状态已经安全写入数据库。应先记录远端结果,再触发相应的状态转换。
简单同步场景:失败时显式抛出异常
如果暂时不需要 processing 状态和异步任务,也可以在 before callback 中调用 API,但失败时必须主动抛出异常:
class Payment
include AASM
class PaymentFailed < StandardError; end
aasm do
state :created, initial: true
state :paid
state :refunded
event :pay, before: :charge_customer do
transitions from: :created, to: :paid
end
end
private
def charge_customer
response = payment_gateway.charge(
amount: amount,
idempotency_key: idempotency_key
)
unless response.success?
raise PaymentFailed, "Payment provider rejected the payment"
end
self.remote_transaction_id = response.transaction_id
self.provider_status = response.status
end
end
调用方需要处理失败情况:
begin
payment.pay!
rescue Payment::PaymentFailed => error
Rails.logger.warn(error.message)
end
这种方式比让 callback 返回失败状态更明确,因为异常会直接中断事件执行。不过,异常是否会由 AASM 的 error callback 处理,以及处理后是否继续向外抛出,取决于 AASM 的配置。项目应针对实际使用的版本编写测试。
为什么数据库事务不能彻底解决问题
即使把整个过程放进数据库事务,外部支付 API 也不会成为事务的一部分:
Payment.transaction do
response = gateway.charge(...)
payment.pay!
end
如果远端扣款成功,但之后数据库提交失败,数据库可以回滚,远端扣款却不会自动撤销。这属于分布式一致性问题,单靠 Active Record transaction 无法保证原子性。
因此,支付流程至少需要做到以下几点:
- 向支付平台传递稳定的 idempotency key。
- 持久化远端交易号和原始状态。
- 超时后使用
unknown或类似状态,不要直接判断为失败并立即重试扣款。 - 通过查询支付平台或接收 webhook 确认最终结果。
- 定期对账,修复“远端成功、本地未更新”的记录。
- 对同一条
Payment使用行锁或其他并发控制手段。
pay 与 pay! 也要区分
在 Active Record 集成中,通常:
payment.pay
主要修改对象在内存中的状态,而:
payment.pay!
会尝试持久化状态。具体行为仍取决于 AASM 的 persistence adapter 和项目配置。
支付完成状态通常必须持久化。因此,测试不能只检查对象是否变成了 paid?,还要覆盖数据库保存失败、校验失败、并发调用和 API 超时等情况。
实际选择
对于风险较低、同步执行且容易人工修复的流程,可以使用 before callback,并在 API 失败时显式抛出异常。
真实支付系统更适合采用下面的状态结构:
created
→ processing
→ paid
→ failed
→ unknown / pending_confirmation
外部服务负责执行支付,AASM 负责限制合法的状态转换,guard 只验证已经持久化的条件。这样的分工能让状态机保持清晰,也为幂等、重试、并发控制和支付对账留出可靠的处理空间。
备注:内容仅供参考。
