Rails:create 出错时如何处理已创建的用户数据?
明确结论
支付失败后,不要再次执行”创建用户”逻辑,也不要直接删除已经创建的账户。更合适的做法是把注册和订阅分成两个可以重试的阶段:
- 创建账户,将状态设为
pending_payment或未订阅。 - 使用该账户的 ID 调用支付服务。
- 如果支付失败,保留账户并重新展示账单表单。
- 用户修改银行卡信息后,为同一个账户重试订阅。
- 支付成功后,将账户状态更新为
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 的页面没有问题,但这只是界面设计。后端需要确保账户只创建一次,之后的提交始终绑定到同一个经过授权的待订阅账户,并允许支付操作安全、幂等地重试。
备注:内容仅供参考。
