PAYATHON 2026

如何验证 Payment 不会使 invoice.amount_payable 小于 0?

支付老李

结论

不要等到 after_save 再检查并回滚。付款应按以下流程处理:

  1. 写入 Payment 前验证可支付余额;
  2. 在同一个数据库事务中锁定对应的 Invoice;
  3. 保存付款后重新计算 amount_payable;
  4. 如果 amount_payable 是数据库字段,再添加 CHECK (amount_payable >= 0) 约束作为最后一道防线。

普通的 Rails validation 只能应对顺序请求。并发创建多笔付款时,各个请求可能读到同一份余额,因此还需要数据库行锁。

自定义验证

假设发票总金额字段是 total_amount,验证时可以先计算除当前付款外,其他付款已经支付的金额:

class Payment < ApplicationRecord
  belongs_to :invoice

  validates :amount,
            numericality: { greater_than: 0 }

  validate :amount_must_not_exceed_available_balance

  private

  def amount_must_not_exceed_available_balance
    return if invoice.nil? || amount.blank?

    other_payments = invoice.payments
    other_payments = other_payments.where.not(id: id) if persisted?

    available_amount =
      invoice.total_amount - other_payments.sum(:amount)

    if amount > available_amount
      errors.add(
        :amount,
        "cannot exceed the available balance of #{available_amount}"
      )
    end
  end
end

这里不能直接使用:

invoice.amount_payable

修改已有付款时,amount_payable 通常已经减去了这笔付款原来的金额。直接拿它比较,可能会拒绝本来合法的修改。验证时应排除当前记录,再计算这笔付款最多可以占用多少余额。

如果 amount_payable 的业务定义并非:

total_amount - payments.sum(:amount)

就需要按项目的实际规则调整计算公式。

使用事务和行锁防止并发超付

只有上述验证还不够。假设余额为 100,两个请求同时创建金额为 80 的付款:

  • 请求 A 读到余额 100;
  • 请求 B 也读到余额 100;
  • 两次验证均通过;
  • 最终支付总额变为 160。

可以将付款操作集中在服务对象中,并锁定发票记录:

class RecordPayment
  def self.call(invoice:, payment:)
    Invoice.transaction do
      invoice.lock!

      payment.invoice = invoice
      payment.save!

      invoice.update!(
        amount_payable: invoice.total_amount -
                          invoice.payments.sum(:amount)
      )

      payment
    end
  end
end

调用方式:

payment = Payment.new(amount: 80)

RecordPayment.call(
  invoice: invoice,
  payment: payment
)

invoice.lock! 通常会在数据库中锁定这条发票记录。针对同一张发票的付款事务会依次执行。后进入的事务需要等待前一个事务结束,再根据最新余额重新验证。

所有创建和修改付款的入口都必须经过这段事务逻辑。如果部分代码绕过服务对象,直接调用 payment.save!,并发保证仍会失效。

更新已有付款

同一个服务也可以处理付款金额的修改:

payment = invoice.payments.find(params[:id])
payment.amount = params[:amount]

RecordPayment.call(
  invoice: invoice,
  payment: payment
)

验证会排除当前付款记录,并根据其他付款已经占用的金额,计算本次付款允许的最大值。

删除付款也应在锁内完成:

class RemovePayment
  def self.call(payment:)
    Invoice.transaction do
      invoice = payment.invoice
      invoice.lock!

      payment.destroy!

      invoice.update!(
        amount_payable: invoice.total_amount -
                          invoice.payments.sum(:amount)
      )
    end
  end
end

这样便可以删除 after_save 和 after_destroy 回调,把金额更新逻辑统一放在事务中,而不是分散在不同的生命周期回调里。

数据库约束

如果 amount_payable 是 invoices 表中的真实字段,可以添加数据库约束:

class AddAmountPayableConstraintToInvoices < ActiveRecord::Migration[7.0]
  def change
    add_check_constraint(
      :invoices,
      "amount_payable >= 0",
      name: "invoices_amount_payable_non_negative"
    )
  end
end

迁移版本号应改为项目实际使用的 Rails 版本。

数据库约束不能代替业务验证,因为它无法为 Payment 提供友好的错误信息。不过,它可以防止程序缺陷、后台脚本或绕过模型验证的写入把余额变成负数。

为什么不建议在 after_save 中回滚

执行 after_save 时,付款记录已经进入保存流程。此时再检查会带来不少问题:

  • 很难将错误正确地添加到需要验证的 Payment 上;
  • 在回调中再次更新,容易触发嵌套回调;
  • 并发事务仍可能根据旧余额完成检查;
  • 抛出异常或执行回滚会让控制流程更难理解;
  • 部分批量写入方法会绕过模型回调。

throw(:abort) 更适合在 before_* 回调中阻止操作,但它解决不了并发读取的问题。ActiveRecord::Rollback 只有放在明确的事务边界内才有可靠的含义,而且通常不会像普通异常那样继续向外抛出,因此不适合用作付款验证机制。

Rails validation 负责提供友好的业务错误,事务和行锁负责保证并发操作正确,数据库约束则负责守住不能突破的底线。

备注:内容仅供参考。