如何为自定义 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_machinecallback 在特定状态转换发生时执行逻辑。- 如果不需要转换回调,只使用
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 时能撤销原有优惠;
- 重复提交不会重复减价;
- 在订单最终完成前重新计算总额;
- 价格由服务端重新计算,不信任浏览器提交的金额。
常见排查点
如果页面可以正常跳转,但字段仍未保存,可以依次检查:
spree_orders中是否存在对应的数据库列。- 表单字段是否通过
f.radio_button等方式生成order[fulfillment_type]。 - 请求参数中是否确实包含该值。
- Rails 3 的批量赋值是否允许写入该属性。
- 模型校验是否导致
update_attributes返回false。 - decorator 是否已被 Rails 自动加载。
- 是否有其他 callback 在状态切换后覆盖该字段。
- 自定义 flow 中是否存在绕过该 step 的 transition。
正确的结构并不是在 checkout_flow 和 state_machine 之间二选一。添加步骤使用 checkout_flow,标准字段保存依赖订单模型和 checkout update。只有需要处理状态转换时机的业务逻辑时,才额外使用底层 state_machine callback。
备注:内容仅供参考。
