PAYATHON 2026

Rails StateMachine 无法从 :new 转换状态

支付阿杰

结论

transition :new => :pending_refund_details 这条定义没有问题。异常通常出在调用位置:在 before_save 回调中调用事件方法,会让保存流程重入;也可能是同一次保存重复触发了 refund_details_not_found。

StateMachine 的事件方法会执行状态转换。与 ActiveRecord 集成时,它通常还会触发 save。因此,不要在 before_save 中直接调用会保存记录的事件方法。可以在保存流程之外触发事件,也可以通过 state_event,让当前这次保存完成转换。

推荐调用方式

在业务代码中直接触发事件:

return_order.refund_details_not_found!

带 ! 的方法在转换失败时会抛出 StateMachine::InvalidTransition。如果希望失败时返回 false,可以使用非 bang 版本:

if return_order.refund_details_not_found
  # 转换成功
else
  # 当前状态不允许转换,或保存失败
end

也可以先检查事件能否执行:

if return_order.can_refund_details_not_found?
  return_order.refund_details_not_found!
end

不要在 before_save 中再次启动保存

下面的写法容易造成保存重入或重复转换:

before_save :set_refund_state

def set_refund_state
  refund_details_not_found!
end

refund_details_not_found! 不只是修改字段。它会按照状态机配置执行转换并持久化记录,而这段代码运行时,当前记录已经处于 save 回调中。

更合适的做法是把触发条件放到业务层:

if return_order.refund_details_missing?
  return_order.refund_details_not_found!
else
  return_order.save!
end

必须随当前保存触发时

可以设置 StateMachine 提供的事件属性,让当前这次 save 执行转换:

return_order.state_event = 'refund_details_not_found'
return_order.save!

如果确实需要在回调中决定触发哪个事件,建议在 before_validation 阶段设置事件名称,并加上明确条件,避免反复赋值:

before_validation :select_state_event

private

def select_state_event
  if state == 'new' && refund_details_missing? && state_event.blank?
    self.state_event = 'refund_details_not_found'
  end
end

可用的事件属性名称取决于状态机绑定的属性。默认状态属性是 state,对应的事件属性为 state_event。如果状态机定义在其他属性上,例如:

state_machine :status, initial: :new do
  event :refund_details_not_found do
    transition new: :pending_refund_details
  end
end

对应的事件属性通常是:

return_order.status_event = 'refund_details_not_found'
return_order.save!

完整配置示例

state_machine :state, initial: :new do
  event :all_products_received do
    transition [:new, :pending_refund_details, :canceled] => :pending_payment
  end

  event :refund_details_not_found do
    transition new: :pending_refund_details
  end

  event :order_return_canceled do
    transition new: :canceled
  end

  event :mistaked_canceled do
    transition canceled: :buyer_dispatched
  end

  event :payment_made do
    transition pending_payment: :payment_complete
  end

  before_transition to: :canceled do |return_order|
    return_order.total = 0
  end

  before_transition to: :pending_payment do |return_order|
    return_order.pending_on = Time.current
  end

  before_transition to: :payment_complete do |return_order|
    return_order.completed_on = Time.current
  end
end

调用:

return_order.reload

if return_order.can_refund_details_not_found?
  return_order.refund_details_not_found!
end

仍然报错时检查

确认以下几点:

  • 数据库中的实际状态是 "new":

    return_order.reload.state
  • 没有其他 before_save、before_validation 或状态机回调提前修改状态。

  • 控制器、服务对象和模型回调没有重复触发同一个事件。

  • 状态机绑定的属性与数据库列一致。使用 status 列时,必须声明 state_machine :status。

  • 不要直接调用类似 refund_details_not_found_transition 的内部转换对象。应调用事件方法或设置事件属性。

  • 使用 refund_details_not_found! 时,异常也可能由验证失败或回调阻止保存引起。此时应检查模型的 errors.full_messages。

核心原则是:把状态机事件作为一次完整的业务操作触发,不要在正在执行的 before_save 中再次启动该事件的保存流程。

备注:内容仅供参考。