保存 payment gateway 响应的数据库设计建议
明确结论
不要把 payment_status、pg_error_message 等支付网关响应直接塞进 bookings 表。更合理的做法是建立独立的支付模型:
Booking has_many :payments
通常也不建议使用 has_one :sale。一次预订可能发生多次支付操作,包括支付失败后的重试、重复回调、退款、部分退款和重新扣款,因此需要保留多条支付记录。
Booking 描述订了什么、谁来住、金额是多少;Payment 或 PaymentTransaction 则记录如何支付以及支付结果。
为什么支付信息应该独立存储
支付和预订有关联,但两者的生命周期不同。
一个 Booking 可能依次经历:
- 创建预订;
- 第一次支付失败;
- 更换银行卡后重新支付;
- 支付成功;
- 部分退款;
- 退还剩余金额。
如果把支付字段直接放在 bookings 表中,每次重试都会覆盖之前的结果。这样既无法保留完整历史,也不利于处理退款和支付网关发送的异步通知。
把支付信息放进独立模型,还可以:
- 保留每次支付尝试及其失败原因;
- 接入不同的支付网关;
- 处理 webhook 和幂等性;
- 避免支付逻辑混入 Booking;
- 支持对账、退款和审计;
- 为以后增加分期、押金或多币种留下扩展空间。
推荐的基础设计
多数酒店预订系统可以先采用下面的关系:
class Booking < ApplicationRecord
has_many :payments, dependent: :restrict_with_error
end
class Payment < ApplicationRecord
belongs_to :booking
end
这里应使用 has_many。即使当前业务规定一张订单只能支付一次,也要考虑失败重试和退款产生的新记录。
payments 表可以包含以下字段:
create_table :payments do |t|
t.references :booking, null: false, foreign_key: true
t.string :provider, null: false
t.string :provider_transaction_id
t.string :status, null: false
t.string :operation_type, null: false, default: "charge"
t.bigint :amount_cents, null: false
t.string :currency, null: false
t.string :error_code
t.text :error_message
t.jsonb :gateway_response
t.datetime :processed_at
t.timestamps
end
add_index :payments,
[:provider, :provider_transaction_id],
unique: true,
where: "provider_transaction_id IS NOT NULL"
如果使用的不是 PostgreSQL,需要把 jsonb 换成数据库支持的 JSON 类型或 text,条件索引的语法也要按实际数据库调整。
各字段的职责如下:
provider:支付网关名称,例如stripe或adyen;provider_transaction_id:支付网关返回的交易标识;status:应用内部统一定义的支付状态;operation_type:操作类型,例如charge、refund、authorization;amount_cents:以最小货币单位保存金额,避免浮点数误差;currency:币种,例如CNY、USD;error_code:支付网关返回的错误码;error_message:供内部排查使用的错误说明;gateway_response:经过筛选或脱敏的原始响应;processed_at:支付网关实际处理该操作的时间。
不要直接使用网关状态作为业务状态
不同支付网关使用的状态名称可能不同。应用应该定义一套统一状态,再把各网关的响应映射到这套状态。
class Payment < ApplicationRecord
belongs_to :booking
enum :status, {
pending: "pending",
authorized: "authorized",
succeeded: "succeeded",
failed: "failed",
canceled: "canceled",
refunded: "refunded",
partially_refunded: "partially_refunded"
}
enum :operation_type, {
charge: "charge",
authorization: "authorization",
capture: "capture",
refund: "refund"
}
end
具体的 enum 写法取决于 Rails 版本。业务代码使用应用内部定义的状态,支付网关的原始状态则保存在单独字段或 gateway_response 中。
Booking 中可以保留什么支付字段
支付详情应放在 payments 表中,不过 Booking 可以保留一个供业务查询使用的汇总状态:
add_column :bookings, :payment_status, :string,
null: false,
default: "unpaid"
可选值可以是:
unpaid
payment_pending
paid
partially_refunded
refunded
这个字段是 Booking 当前支付结果的快照,方便列表查询和业务判断,但不能作为支付流水的唯一事实来源。更新时应根据关联的支付记录计算,并尽量和相关支付记录放在同一个数据库事务中处理。
还要注意区分两类状态:
booking_status:预订处于待确认、已确认、已取消还是已完成状态;payment_status:款项处于未支付、支付中、已支付还是已退款状态。
支付成功并不必然表示酒店已经确认预订。
什么时候需要进一步拆分模型
业务简单时,一个 payments 表通常已经够用。随着业务变复杂,可以进一步拆成支付单和支付交易两层:
class Booking < ApplicationRecord
has_many :payments
end
class Payment < ApplicationRecord
belongs_to :booking
has_many :payment_transactions
end
class PaymentTransaction < ApplicationRecord
belongs_to :payment
end
其中:
Payment表示业务上的应付记录;PaymentTransaction表示每一次发送给支付网关的操作;- 支付、重试、退款和撤销分别生成不同的 transaction。
以下场景更适合这种设计:
- 允许多次支付尝试;
- 支持部分支付或分期;
- 存在押金和尾款;
- 一次支付可能发生多次退款;
- 同时接入多个支付网关;
- 对财务对账和审计有严格要求。
如果项目当前规模较小,先使用 Booking has_many :payments 即可,不必过早拆出太多表。
创建支付记录的示例
发送支付请求前,先创建本地记录:
payment = booking.payments.create!(
provider: "example_gateway",
status: "pending",
operation_type: "charge",
amount_cents: booking.total_price_cents,
currency: booking.currency
)
收到支付网关的结果后,再更新这条记录:
payment.update!(
provider_transaction_id: response.transaction_id,
status: map_gateway_status(response.status),
error_code: response.error_code,
error_message: response.error_message,
gateway_response: sanitize_gateway_response(response),
processed_at: Time.current
)
支付成功时,可以同步更新 Booking 的汇总状态:
Booking.transaction do
payment.update!(
status: "succeeded",
provider_transaction_id: response.transaction_id,
processed_at: Time.current
)
booking.update!(payment_status: "paid")
end
实际系统通常还要通过支付网关的 webhook 确认最终结果,不能只依赖用户完成支付后的页面跳转。
Webhook 和重复回调处理
支付网关可能多次发送同一个事件,因此回调处理必须具备幂等性。可以用单独的表保存 webhook event:
create_table :payment_events do |t|
t.string :provider, null: false
t.string :provider_event_id, null: false
t.string :event_type, null: false
t.jsonb :payload
t.datetime :processed_at
t.timestamps
end
add_index :payment_events,
[:provider, :provider_event_id],
unique: true
处理回调时,先验证签名,再通过 provider_event_id 判断该事件是否已经处理。不要只凭 Booking ID 更新支付状态,也不要假设支付网关只会发送一次回调。
安全和数据保存注意事项
不要长期保存未经处理的完整支付网关响应,其中可能含有敏感信息或不适合长期存储的数据。
至少应做到以下几点:
- 不保存银行卡号、CVV、支付密码等信息;
- 筛选或脱敏 token、手机号、邮箱和身份信息;
- 不把供内部排查使用的
error_message原样显示给用户; - 在用户界面中使用整理后的提示,例如 “支付未完成,请更换支付方式后重试”;
- 为 webhook payload 和支付响应设置访问权限及保留期限;
- 用整数形式的最小货币单位保存金额,不使用
float; - 每条金额记录都同时保存币种;
- 为支付网关交易 ID 和 webhook event ID 建立唯一索引;
- 不要在删除 Booking 时直接删除支付记录,财务数据通常需要保留。
最终建议
当前模型可以从下面的结构开始:
Booking has_many :payments
Payment belongs_to :booking
支付网关交易 ID、支付状态、错误码、错误信息以及脱敏后的响应都放在 payments 表中。bookings 表最多保留一个方便业务查询的汇总支付状态。
Sale 并非不能使用,但 Payment 或 PaymentTransaction 对职责的表达更准确。除非业务明确把销售单建模为独立的领域对象,否则没有必要仅为了保存支付网关响应而引入含义模糊的 Sale。
备注:内容仅供参考。
