PAYATHON 2026

Solidus / Spree 覆盖 CheckoutController#update 报错

支付老李

结论

报错是因为缺少命名空间。OrderUpdateAttributes 实际定义在 Spree 命名空间中,因此装饰器里的代码应改为:

Spree::OrderUpdateAttributes.new(
  @order,
  update_params,
  request_env: request.headers.env
).apply

如果你只是想在订单完成后执行某些操作,不建议复制并覆盖整个 CheckoutController#update。更稳妥的方式是为订单状态机添加完成回调。无论订单通过前台、后台、API 还是其他流程完成,回调都能执行,也不会在 Solidus 升级并修改 controller 实现后遗漏新增逻辑。

为什么原 controller 中可以省略命名空间

Solidus 原始 controller 通常定义在 Spree 模块中,例如:

module Spree
  class CheckoutController < Spree::StoreController
    def update
      OrderUpdateAttributes.new(...)
    end
  end
end

按照 Ruby 的常量查找规则,这里的 OrderUpdateAttributes 会被解析为:

Spree::OrderUpdateAttributes

装饰器则从顶层调用:

Spree::CheckoutController.class_eval do
  # ...
end

class_eval 的 block 会保留其定义位置的词法作用域,所以其中没有命名空间限定的 OrderUpdateAttributes 未必能解析到 Spree::OrderUpdateAttributes,最终会出现:

uninitialized constant OrderUpdateAttributes

这通常不表示 controller decorator 没有加载。装饰器如果没有加载,覆盖后的 update 方法就不会执行,自然也不会在这一行报错。

直接修复当前覆盖代码

改动最少的办法是补全常量名:

Spree::CheckoutController.class_eval do
  def update
    if Spree::OrderUpdateAttributes.new(
      @order,
      update_params,
      request_env: request.headers.env
    ).apply
      @order.temporary_address = !params[:save_user_address]

      success =
        if @order.state == 'confirm'
          @order.complete
        else
          @order.next
        end

      unless success
        flash[:error] = @order.errors.full_messages.join("\n")
        redirect_to(checkout_state_path(@order.state))
        return
      end

      if @order.completed?
        @current_order = nil
        flash.notice = Spree.t(:order_processed_successfully)
        flash['order_completed'] = true
        redirect_to completion_route
      else
        redirect_to checkout_state_path(@order.state)
      end
    else
      render :edit
    end
  end
end

如果只是要修复当前异常,这样改就够了。不过,这个方法复制自 Solidus 的内部实现。以后升级 Solidus 时,需要对照新版源码检查其中的差异。

更合适的方案:监听订单完成状态

订单完成是领域事件,不应依附于某个 controller。可以在 Spree::Order 的状态机中注册回调:

Spree::Order.class_eval do
  state_machine.after_transition to: :complete, do: :run_after_checkout_action

  private

  def run_after_checkout_action
    MyOrderCompletionService.call(self)
  end
end

再把业务逻辑放到独立的服务对象中:

class MyOrderCompletionService
  def self.call(order)
    new(order).call
  end

  def initialize(order)
    @order = order
  end

  def call
    # 订单完成后的操作
  end
end

这样处理有几个好处:

  • 无需复制 Solidus 的 checkout 流程。
  • 不依赖某个特定的 controller 入口。
  • 通过后台、API 等其他途径完成订单时也会触发。
  • Solidus 升级后,即使内部 controller 发生变化,也不容易受到影响。

状态机 API 可能因 Solidus 及其状态机依赖的版本不同而有所变化。如果当前版本不支持上述 after_transition 写法,应以该版本中 Spree::Order 的状态机定义为准。整体思路不变,也就是把业务逻辑挂到订单进入 complete 状态的事件上。

装饰器的加载

Rails 4 的开发环境会重新加载常量。如果项目没有自动加载 decorator,可以在初始化配置中通过 to_prepare 加载:

Rails.application.config.to_prepare do
  require_dependency Rails.root.join(
    'app/controllers/spree/checkout_controller_decorator'
  ).to_s
end

模型装饰器也可以采用相同方式:

Rails.application.config.to_prepare do
  require_dependency Rails.root.join(
    'app/models/spree/order_decorator'
  ).to_s
end

是否需要手动配置,要看当前使用的 Solidus 版本和项目现有的 decorator 加载设置。由于这次错误已经出现在覆盖后的方法内部,说明该方法确实得到了执行。因此,应先修正 Spree::OrderUpdateAttributes,不用把排查重点放在加载配置上。

注意事项

完成回调可能会发送邮件、调用外部接口或创建其他记录,因此需要考虑幂等性。即使正常状态机只会完成一次订单,任务重试、数据修复或并发请求仍可能导致操作重复执行。可以保存处理标记,也可以把订单号用作外部系统的幂等键。

如果相关操作必须等数据库事务提交后才能运行,还应避免在事务提交前直接调用外部服务。回调可以先创建后台任务,再让任务在提交后执行。具体采用哪种事务回调方式,取决于项目使用的 Rails 和 Solidus 版本。

补上 Spree:: 就能解决当前异常。如果需求是处理“订单已完成”这个业务事件,使用状态机回调或独立的事件处理逻辑,会比覆盖整个 CheckoutController#update 更可靠。

备注:内容仅供参考。