PAYATHON 2026

如何为自定义 Spree checkout step 添加保存逻辑

支付小周

明确结论

在 Spree 1.3.2 中,添加自定义 checkout step 应使用 checkout_flow DSL。它不是一套独立于 state_machine 的机制,而是 Spree 配置订单 checkout 状态机所用的 DSL。

通常不需要手工重建另一套 state_machine:

  • 使用 checkout_flow 定义步骤和顺序。
  • 使用订单属性和 CheckoutController#update 保存表单数据。
  • 需要在状态切换前后执行计价、校验等业务逻辑时,再为底层状态机添加 transition callback。

如果用户选择无法保存,问题通常不在 step 配置,而在于表单字段没有提交为 order[...]、数据库缺少对应字段,或 Rails 3 的批量赋值机制不允许写入该字段。

1. 为订单增加持久化字段

如果每个订单只需保存 pickup 或 delivery,可以在 spree_orders 表中增加一个字段。布尔字段也可以使用,但字符串或枚举式字段更便于扩展和校验。

class AddFulfillmentTypeToSpreeOrders < ActiveRecord::Migration
  def change
    add_column :spree_orders, :fulfillment_type, :string
  end
end

迁移后执行:

bundle exec rake db:migrate

然后为订单添加校验和业务方法:

# app/models/spree/order_decorator.rb
Spree::Order.class_eval do
  attr_accessible :fulfillment_type

  validates :fulfillment_type,
            inclusion: { in: %w[pickup delivery] },
            allow_nil: true

  def pickup?
    fulfillment_type == "pickup"
  end

  def delivery?
    fulfillment_type == "delivery"
  end
end

Spree 1.3.2 通常运行在 Rails 3 环境中,需要注意 attr_accessible。如果项目未启用受保护属性,或已经通过其他方式开放该字段,就不必重复声明。

2. 使用 checkout_flow 插入步骤

假设自定义状态名为 fulfillment,并将其放在 delivery 和 payment 之间:

# app/models/spree/order_decorator.rb
Spree::Order.class_eval do
  checkout_flow do
    go_to_state :address
    go_to_state :delivery
    go_to_state :fulfillment
    go_to_state :payment,
                if: lambda { |order| order.payment_required? }
    go_to_state :confirm,
                if: lambda { |order| order.confirmation_required? }
    go_to_state :complete

    remove_transition from: :delivery, to: :payment
    remove_transition from: :delivery, to: :confirm
  end
end

这里要完整描述 checkout flow,不能只追加一个孤立状态。remove_transition 用于阻止原有状态直接绕过 fulfillment。

需要删除哪些 transition,要根据当前订单模型原有的 checkout flow 判断。例如,免付款订单可能从 delivery 直接进入 confirm。除了常规的 delivery -> payment,还要检查条件分支是否会跳过自定义步骤。

Spree 1.3.2 属于较早的版本。建议先查看项目实际安装版本中 Spree::Order 的 checkout flow 定义,再在 decorator 中复制原有流程并插入所需状态。不要直接套用其他 Spree 版本的状态列表,因为不同版本的默认流程和 DSL 细节可能不同。

3. 让表单按订单参数提交

自定义页面的字段必须放在 order 参数下,Spree 的 checkout 更新逻辑才能通过 update_attributes 保存它。

<%= form_for @order,
             url: update_checkout_path(@order.state),
             method: :put do |f| %>

  <label>
    <%= f.radio_button :fulfillment_type, "pickup" %>
    Pickup
  </label>

  <label>
    <%= f.radio_button :fulfillment_type, "delivery" %>
    Delivery
  </label>

  <%= submit_tag "Save and Continue" %>
<% end %>

提交参数应类似:

{
  "state" => "fulfillment",
  "order" => {
    "fulfillment_type" => "pickup"
  }
}

如果表单提交的是顶层参数:

{
  "fulfillment_type" => "pickup"
}

Spree 默认的 checkout 更新逻辑通常不会将其保存为订单属性。

只要字段、数据库迁移和 attr_accessible 都配置正确,一般无需为了保存这个值而重写整个 CheckoutController#update。

4. 在进入下一步前校验选择

需要阻止用户在未选择任何方式时进入 payment step:

# app/models/spree/order_decorator.rb
Spree::Order.class_eval do
  validate :fulfillment_type_selected,
           if: lambda { |order| order.state == "fulfillment" }

  private

  def fulfillment_type_selected
    unless %w[pickup delivery].include?(fulfillment_type)
      errors.add(:fulfillment_type, "must be selected")
    end
  end
end

旧版 Spree 在推进 checkout 状态时会保存并验证订单。验证失败后,页面应停留在当前步骤并显示错误。

也可以使用状态转换守卫控制订单能否继续,但对于普通表单字段,Active Record 校验通常更直观。

5. 在状态转换时执行计价逻辑

保存用户选择与根据选择重新计价是两件事。字段保存可以交给正常的订单更新流程,价格调整则应放在模型或独立服务中,不要只写在 view 里。

如果费用必须在订单从 fulfillment 进入下一步前更新,可以为底层状态机添加 callback:

# app/models/spree/order_decorator.rb
Spree::Order.class_eval do
  state_machine do
    before_transition from: :fulfillment,
                      to: :payment,
                      do: :apply_fulfillment_pricing
  end

  private

  def apply_fulfillment_pricing
    if pickup?
      # 删除或更新已有的 delivery adjustment
      # 然后重新计算订单总额
    else
      # 建立或恢复 delivery 对应的费用
    end
  end
end

这里同时使用 checkout_flow 和 state_machine 并不冲突:

  • checkout_flow 定义 checkout 包含哪些状态,以及状态如何推进。
  • state_machine callback 在特定状态转换发生时执行逻辑。
  • 如果不需要转换回调,只使用 checkout_flow 即可。

还要考虑条件路径。当 payment_required? 返回 false 时,订单可能从 fulfillment 直接进入 confirm 或其他状态。此时只监听 to: :payment 会漏掉免付款订单。可以覆盖离开该步骤的所有可能路径,或者只指定来源状态:

Spree::Order.class_eval do
  state_machine do
    before_transition from: :fulfillment,
                      do: :apply_fulfillment_pricing
  end
end

这个方法应当具备幂等性,因为用户可能返回上一步,修改选择后再次提交。不要在每次转换时都无条件新增 adjustment,否则订单中可能出现重复费用。

6. Pickup 降价的实现建议

如果所谓的“delivery 价格”实际是运费,可以优先将 pickup 实现为一种 shipping method,并为其配置零运费或更低的计算器费用。这样能直接复用 Spree 现有的 shipment、shipping rate、adjustment 和订单重算机制。

只有当 pickup/delivery 选择影响的是运费之外的独立业务价格时,才更适合维护自定义 adjustment。无论使用哪种方式,都应避免直接修改商品行项目的原始价格,否则用户切换方式或重新计算订单时,很难恢复原价。

自定义 adjustment 至少需要做到:

  • 能识别并更新已有记录;
  • 用户从 pickup 切换到 delivery 时能撤销原有优惠;
  • 重复提交不会重复减价;
  • 在订单最终完成前重新计算总额;
  • 价格由服务端重新计算,不信任浏览器提交的金额。

常见排查点

如果页面可以正常跳转,但字段仍未保存,可以依次检查:

  1. spree_orders 中是否存在对应的数据库列。
  2. 表单字段是否通过 f.radio_button 等方式生成 order[fulfillment_type]。
  3. 请求参数中是否确实包含该值。
  4. Rails 3 的批量赋值是否允许写入该属性。
  5. 模型校验是否导致 update_attributes 返回 false。
  6. decorator 是否已被 Rails 自动加载。
  7. 是否有其他 callback 在状态切换后覆盖该字段。
  8. 自定义 flow 中是否存在绕过该 step 的 transition。

正确的结构并不是在 checkout_flow 和 state_machine 之间二选一。添加步骤使用 checkout_flow,标准字段保存依赖订单模型和 checkout update。只有需要处理状态转换时机的业务逻辑时,才额外使用底层 state_machine callback。

备注:内容仅供参考。