在 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 这样的浮点数。
是否应该把流水作为唯一数据源
通常有两种方案:
- 每次通过
SUM(amount)计算余额; - 保存流水,同时维护
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 数据完整,还需要独立流水、数据库约束、幂等处理和事务锁。
备注:内容仅供参考。
