PAYATHON 2026

保存 payment gateway 响应的数据库设计建议

支付小周

明确结论

不要把 payment_status、pg_error_message 等支付网关响应直接塞进 bookings 表。更合理的做法是建立独立的支付模型:

Booking has_many :payments

通常也不建议使用 has_one :sale。一次预订可能发生多次支付操作,包括支付失败后的重试、重复回调、退款、部分退款和重新扣款,因此需要保留多条支付记录。

Booking 描述订了什么、谁来住、金额是多少;Payment 或 PaymentTransaction 则记录如何支付以及支付结果。

为什么支付信息应该独立存储

支付和预订有关联,但两者的生命周期不同。

一个 Booking 可能依次经历:

  1. 创建预订;
  2. 第一次支付失败;
  3. 更换银行卡后重新支付;
  4. 支付成功;
  5. 部分退款;
  6. 退还剩余金额。

如果把支付字段直接放在 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。

备注:内容仅供参考。