PAYATHON 2026

Ruby AASM 支付处理中如何结合 before callback 与 guard

支付小周

结论

不建议把“在 before callback 中调用支付 API,再通过 attr_accessor 把结果传给 guard”作为主要方案。

这种写法在某些 AASM 配置和调用路径下能够运行,但它把不同职责混在了一起,遇到重复调用、并发、进程重启或数据库保存失败时,很容易出问题。更可靠的设计是:

  1. AASM 只描述支付状态和合法的状态转换。
  2. 由 service object 调用外部支付 API。
  3. 根据明确的 API 结果触发“支付成功”或“支付失败”事件。
  4. 增加 processing 状态,并记录支付请求、响应和远端交易号。
  5. 通过幂等键、数据库锁和对账机制处理重复请求及网络不确定性。

如果业务很简单,也可以在 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 只验证已经持久化的条件。这样的分工能让状态机保持清晰,也为幂等、重试、并发控制和支付对账留出可靠的处理空间。

备注:内容仅供参考。