PAYATHON 2026

Rails:create 出错时如何处理已创建的用户数据?

支付阿杰

明确结论

支付失败后,不要再次执行”创建用户”逻辑,也不要直接删除已经创建的账户。更合适的做法是把注册和订阅分成两个可以重试的阶段:

  1. 创建账户,将状态设为 pending_payment 或未订阅。
  2. 使用该账户的 ID 调用支付服务。
  3. 如果支付失败,保留账户并重新展示账单表单。
  4. 用户修改银行卡信息后,为同一个账户重试订阅。
  5. 支付成功后,将账户状态更新为 active。

前端仍可使用一个表单,但后端不能把整个流程视为一次不可重试的 create 操作。

为什么不适合重新创建账户

用户第一次提交后,账户记录已经写入数据库。如果第二次提交仍然调用 User.create,就会触发 username 或 email 的唯一性校验。

支付失败不等于注册数据无效。常见原因包括:

  • 卡号输入错误;
  • 有效期或 CVV 错误;
  • 银行拒绝交易;
  • 支付服务暂时不可用;
  • 网络超时,无法确认支付结果。

账户创建和订阅结果应当分别记录。若把支付失败当作”用户创建失败”,后续重试、故障恢复和支付结果核对都会更麻烦。

推荐的数据模型

可以在用户表中保存账户状态:

class AddStatusToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :status, :string, null: false, default: "pending_payment"
    add_index :users, :status
  end
end

模型中限制允许的状态:

class User < ApplicationRecord
  has_secure_password

  validates :username, presence: true, uniqueness: true
  validates :email, presence: true, uniqueness: true

  validates :status,
            inclusion: {
              in: %w[pending_payment active payment_failed]
            }
end

如果系统还有试用、暂停、取消等状态,建议分别保存”账户状态”和”订阅状态”,不要全部放进 users.status。

将流程拆成两个端点

注册端点只负责创建账户:

class RegistrationsController < ApplicationController
  def create
    user = User.new(user_params)
    user.status = "pending_payment"

    if user.save
      session[:pending_user_id] = user.id
      redirect_to new_subscription_path
    else
      @user = user
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:username, :email, :password)
  end
end

订阅端点使用已经创建的账户,不再调用 User.create:

class SubscriptionsController < ApplicationController
  before_action :load_pending_user

  def new
  end

  def create
    result = Billing::CreateSubscription.call(
      user: @user,
      payment_token: params.require(:payment_token)
    )

    if result.success?
      @user.update!(status: "active")
      session.delete(:pending_user_id)

      redirect_to dashboard_path, notice: "订阅已开通"
    else
      @user.update!(status: "payment_failed")
      flash.now[:alert] = result.message

      render :new, status: :unprocessable_entity
    end
  end

  private

  def load_pending_user
    @user = User.find_by(id: session[:pending_user_id])

    return if @user&.status.in?(%w[pending_payment payment_failed])

    redirect_to new_registration_path, alert: "注册会话已失效,请重新开始"
  end
end

这里的 session[:pending_user_id] 只是示例。账户创建后也可以直接登录用户,再通过 current_user 完成订阅。无论采用哪种方式,服务端都必须确认当前访问者有权为该账户提交支付信息,不能直接信任浏览器传来的 user_id。

支付逻辑放进服务对象

支付服务调用、异常转换和状态判断不必全部放在控制器中:

module Billing
  class CreateSubscription
    Result = Data.define(:success?, :message)

    def self.call(user:, payment_token:)
      new(user:, payment_token:).call
    end

    def initialize(user:, payment_token:)
      @user = user
      @payment_token = payment_token
    end

    def call
      response = PaymentGateway.create_subscription(
        reference: @user.id.to_s,
        payment_token: @payment_token,
        idempotency_key: "initial-subscription-#{@user.id}"
      )

      @user.update!(
        billing_customer_id: response.customer_id,
        subscription_id: response.subscription_id
      )

      Result.new(success?: true, message: nil)
    rescue PaymentGateway::CardError => e
      Result.new(success?: false, message: e.message)
    end
  end
end

PaymentGateway、异常类型和参数名称应根据实际支付服务的 SDK 调整。示例中的 idempotency_key 说明的是幂等设计原则,并非所有服务都使用这个参数名。

保持单页体验也可以

如果产品要求用户始终停留在同一个页面,后端仍然可以按两个阶段处理。第一次提交时创建账户并尝试支付。支付失败后,同一页面只提交支付字段,同时使用服务器签名的账户标识,或从 session 中读取账户标识。

第二次提交必须进入”重试支付”分支:

if pending_user
  retry_subscription_for(pending_user)
else
  create_user_and_start_subscription
end

不能只根据 email 在整个数据库中找到账户,然后继续支付。攻击者可能借此使用已注册的邮箱操作他人账户。授权时必须结合登录身份、注册 session、一次性签名令牌或邮箱验证。

是否可以用数据库事务回滚用户

理论上可以这样写:

User.transaction do
  user = User.create!(user_params)
  BillingService.subscribe!(user, payment_token)
end

但这种方案通常不适合实际支付流程。数据库事务只能回滚本地数据,无法撤销第三方系统中的操作。比如支付服务已经创建客户或完成扣款,之后本地数据库更新失败。此时本地事务虽然回滚,外部操作仍然存在。

只有当支付服务只是同步验证卡片、不产生任何外部状态,而且所有失败情况都很明确时,事务方案才可能适用。实际支付接口往往还涉及超时、异步确认、Webhook 和重复请求,因此更适合使用可恢复的状态机,不能依赖一个横跨数据库和外部 API 的事务。

需要特别注意的事项

  • 不要在自己的数据库、日志或 session 中保存原始卡号、CVV 等敏感数据。通常应使用支付服务提供的托管表单或客户端 SDK,将卡片信息转换成 token。
  • 为创建客户、创建订阅或扣款请求设置幂等键,避免用户重复点击或网络重试导致重复扣费。
  • 前端可以阻止用户重复点击提交按钮,但这不能作为唯一保障。
  • 记录每次支付尝试的状态和第三方请求 ID,以便处理超时和核对 Webhook。
  • 支付请求超时后,不要立即认定支付失败并再次扣款。应先通过幂等键或查询接口确认第三方处理结果。
  • pending_payment 账户可能长期无人继续操作,可以定期清理或匿名化。具体策略需要符合业务对邮箱保留、审计和合规的要求。
  • 支付成功状态最好以通过签名验证的 Webhook 或支付服务的最终查询结果为准,尤其是支付可能异步确认时。

支付失败后展示只包含 billing data 的页面没有问题,但这只是界面设计。后端需要确保账户只创建一次,之后的提交始终绑定到同一个经过授权的待订阅账户,并允许支付操作安全、幂等地重试。

备注:内容仅供参考。