PAYATHON 2026

在 Rails 4 中安全存储用户账户 credit

支付老李

结论

不要只在 users 表中增加一个可以随意修改的 credit 字段。更稳妥的设计包括:

  • 用独立的 credit 流水表记录每次充值、消费、退款和人工调整;
  • 如果需要快速查询余额,可以在用户账户表中保存汇总余额;
  • 付款入账必须在数据库事务中完成,并通过唯一约束确保同一笔支付只入账一次;
  • credit 应使用整数存储最小单位,或使用定点 decimal,不能使用 float;
  • 没有必要为 credit 单独部署另一套数据库。

余额用于快速读取,流水则用于审计、核对和恢复。

推荐的数据结构

如果 credit 是不可分割的点数,可以直接使用整数:

class AddCreditBalanceToUsers < ActiveRecord::Migration
  def change
    add_column :users, :credit_balance, :integer,
               null: false,
               default: 0
  end
end

再建立一张独立的账户流水表:

class CreateCreditTransactions < ActiveRecord::Migration
  def change
    create_table :credit_transactions do |t|
      t.references :user, null: false, index: true
      t.integer :amount, null: false
      t.string :operation, null: false
      t.string :payment_reference
      t.string :description
      t.timestamps null: false
    end

    add_index :credit_transactions,
              :payment_reference,
              unique: true
  end
end

amount 建议使用带符号整数:

  • 充值 100 credit:amount = 100
  • 消费 30 credit:amount = -30
  • 退款 30 credit:amount = 30

如果一笔支付可能产生多条流水,或者并非每条记录都有 payment_reference,可以改用含义更明确的幂等键,例如 event_key,并为其建立唯一索引。

模型可以这样写:

class User < ActiveRecord::Base
  has_many :credit_transactions
end

class CreditTransaction < ActiveRecord::Base
  belongs_to :user

  validates :amount,
            numericality: { only_integer: true, other_than: 0 }

  validates :operation, presence: true
end

Rails 层的验证可以尽早发现错误,但防止重复入账最终仍要依靠数据库唯一约束。

安全地完成付款入账

不要在支付回调中直接执行以下代码:

user.credit_balance += credits
user.save!

当两个请求同时执行时,它们可能读到同一个旧余额,随后写入的结果会互相覆盖,导致 credit 丢失。支付平台重试回调时,还可能造成重复入账。

付款入账应同时使用事务、行锁和幂等键:

class CreditAccount
  def self.apply_payment!(user, credits, payment_reference)
    raise ArgumentError, "credits must be positive" unless credits.to_i > 0
    raise ArgumentError, "payment_reference is required" if payment_reference.blank?

    User.transaction do
      user.with_lock do
        transaction = user.credit_transactions.create!(
          amount: credits.to_i,
          operation: "payment",
          payment_reference: payment_reference
        )

        user.update!(
          credit_balance: user.credit_balance + transaction.amount
        )

        transaction
      end
    end
  rescue ActiveRecord::RecordNotUnique
    user.credit_transactions.find_by!(
      payment_reference: payment_reference
    )
  end
end

数据库事务使流水和余额要么同时写入成功,要么同时回滚。with_lock 可以防止同一账户的并发更新互相覆盖,唯一索引则阻止同一个 payment_reference 被重复记账。

捕获 ActiveRecord::RecordNotUnique 后返回已有记录,适合将重复请求视为幂等成功。如果业务还需要确认已有记录的用户、金额和支付状态是否一致,就应继续校验,不能只因引用号相同便直接接受。

付款状态必须在服务端验证

客户端跳转到”付款成功”页面,并不表示付款已经完成。只有服务端确认支付成功后,才能将 credit 记入账户。

对于 custom payment system,至少应确认:

  • 支付记录确实存在;
  • 支付状态已经变为成功;
  • 实际支付金额、币种、订单和用户相互匹配;
  • 回调签名或其他认证信息有效;
  • 支付引用号没有被其他账户使用;
  • 一笔支付只触发一次 credit 入账。

不要接受客户端直接提交的 credits 数量。这个值应由服务端根据可信的订单数据计算:

credits = order.credit_amount

CreditAccount.apply_payment!(
  order.user,
  credits,
  order.payment_reference
)

否则,攻击者可能篡改请求参数,给自己的账户增加任意数量的 credit。

扣减 credit 也要锁定账户

消费 credit 时,应在锁内检查余额并完成扣减:

class InsufficientCredit < StandardError; end

class CreditAccount
  def self.spend!(user, credits, reference)
    amount = credits.to_i
    raise ArgumentError, "credits must be positive" unless amount > 0

    User.transaction do
      user.with_lock do
        raise InsufficientCredit if user.credit_balance < amount

        user.credit_transactions.create!(
          amount: -amount,
          operation: "purchase",
          payment_reference: reference
        )

        user.update!(
          credit_balance: user.credit_balance - amount
        )
      end
    end
  end
end

余额检查必须在锁内进行。如果先检查余额,之后再扣减,就会产生竞态条件。两个并发请求可能同时通过检查,最终使余额变成负数。

整数还是 decimal

如果 credit 只能是整数,可以使用 integer;数量可能很大时,则使用 bigint。

如果允许小数,可以使用定点数:

add_column :users, :credit_balance,
           :decimal,
           precision: 18,
           scale: 4,
           null: false,
           default: 0

流水表中的 amount 应使用完全相同的类型和精度。不要使用 float,因为二进制浮点数无法精确表示许多十进制数,累计计算时可能产生舍入误差。

如果 credit 实际代表货币,最好用整数保存最小货币单位,例如分,而不是将金额保存成 12.99 这样的浮点数。

是否应该把流水作为唯一数据源

通常有两种方案:

  1. 每次通过 SUM(amount) 计算余额;
  2. 保存流水,同时维护 credit_balance 汇总字段。

第一种结构简单,但随着流水量增加,频繁求和的开销也会增大。第二种读取速度更快,适合需要经常展示或校验余额的应用,但余额和流水必须在同一个事务中更新。

实际项目通常采用第二种方案,并定期进行核对:

ledger_balance = user.credit_transactions.sum(:amount)

if ledger_balance != user.credit_balance
  # 记录异常并进入人工核查流程
end

发现不一致时,不要直接覆盖余额。差异可能来自并发缺陷、重复处理或未经授权的数据修改,应先记录异常并调查原因。

额外注意事项

  • 不要删除或直接修改已经入账的流水。修正错误时,应新增一条冲正记录,以保留完整历史。
  • 管理后台的人工调整也要写入流水,并记录操作者、原因和关联编号。
  • 不要依赖 Rails callback 完成关键记账。其他保存路径、重试或异常可能让 callback 产生难以追踪的副作用。
  • 余额只能通过专门的 service object 修改,不要将 credit_balance 开放给 mass assignment。
  • 如果数据库支持,可以增加 CHECK (credit_balance >= 0) 等约束。Rails 4 对数据库约束的抽象支持有限,具体写法取决于实际使用的是 PostgreSQL、MySQL 还是其他数据库。
  • 应分别测试支付回调、重复请求、并发充值、并发消费、事务回滚和退款。
  • credit 数据与其他业务数据放在同一个关系数据库中即可。单独使用一个数据库不会自动提高安全性,反而会增加跨库事务和一致性处理的复杂度。

users.credit_balance 可以保留,但不能作为唯一记录。要保证 credit 数据完整,还需要独立流水、数据库约束、幂等处理和事务锁。

备注:内容仅供参考。