如何验证 Payment 不会使 invoice.amount_payable 小于 0?
结论
不要等到 after_save 再检查并回滚。付款应按以下流程处理:
- 写入
Payment前验证可支付余额; - 在同一个数据库事务中锁定对应的
Invoice; - 保存付款后重新计算
amount_payable; - 如果
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 负责提供友好的业务错误,事务和行锁负责保证并发操作正确,数据库约束则负责守住不能突破的底线。
备注:内容仅供参考。
