关于 spree-workflows
spree-workflows 是介绍 Spree 服务与工作流架构的开发者技能,涵盖扩展钩子和结果处理,帮助团队在合适位置加入业务逻辑,而不必修补核心操作。
主要功能
- 01服务与工作流的职责划分
- 02验证、上下文和生命周期钩子
- 03钩子注册与拒绝处理
- 04工作流扩展点目录
- 05事件与替换操作的使用指南
适用场景
- 01根据资格规则阻止结账
- 02为税费或促销计算添加上下文
- 03在生命周期钩子中写入关联记录
- 04选择钩子、事件或工作流实现扩展
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-workflows安装后在 Claude Code 中输入/spree-workflows确认已加载。
附带文件references/hooks.md
详细介绍
Spree 工作流与钩子
命令使用 Spree CLI(
spree …)。在没有 CLI 的经典 Rails 应用中,请在应用根目录使用bin/rails …/bundle exec rake …。以下路径假设 Rails 应用位于server/。
Spree 的业务逻辑位于两类普通 Ruby 类中,它们以相同方式调用,并返回相同的 Spree::ServiceModule::Result:
| 层级 | 所在位置 | 适用场景 |
|---|---|---|
| Service | app/services/ | 普通的创建/更新/删除操作,包含一个顺序执行的 call 主体。Spree 中的大多数操作都属于此类。 |
Workflow(Spree::Workflow) | app/workflows/ | 需要扩展点、外部调用(网关、承运商)或补偿(后续步骤失败时撤销操作)的流程:添加/完成购物车、取消订单、捕获付款、履行、退货、商品状态变更、导入等。 |
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 2)
result.success? # never raise on expected failures — check this
result.value # the line item
result.error.to_s # message on failure; result.error.value is ActiveModel::Errors for rejections
Spree 中没有状态机。记录包含普通的 status 字符串(Spree::HasStatus),每次状态转换都是一个带有自己钩子的工作流。
自定义逻辑应该放在哪里——先做判断
| 你想要…… | 使用 |
|---|---|
| 阻止某项操作(购买限额、B2B 资格、欺诈拦截、退货政策) | validate 钩子 |
| 为计算提供数据(免税、促销资格、承运商数据) | 上下文钩子(set_*_context、get_provider_data、extend_payload) |
| 在操作变更时原子性地写入相关记录 | 生命周期钩子(after_*,其中一些会在事务内运行) |
| 发送电子邮件、调用 webhook、更新搜索索引、同步 ERP | 事件订阅者——spree-events-webhooks |
| 添加结账要求(客户必须提供 X) | Spree::Checkout::Registry.add_requirement——spree-checkout |
| 替换整个操作 | Spree.dependencies——spree-dependencies(最后手段;你需要自行维护替换副本) |
| 添加全新的多步骤操作 | 你自己的 Spree::Workflow(见下文) |
尽量选择影响范围最小的方式。钩子可以跨版本升级保留;替换类则必须在每次发布时重新同步。
不要在模型上放置业务行为(会写入数据的新公共模型方法、after_save 副作用、状态转换回调),也不要装饰或修补 finalize!、Order#cancel、Payment#capture! 等方法——它们是已弃用的外壳,会委托给工作流(Order#finalize! → Spree.order_complete_workflow),而实际流程(Spree::Carts::Complete、Spree::Orders::Cancel、Spree::Payments::Capture)不会调用你的重写方法。请改用工作流上的钩子。
注册钩子
在 config/initializers/spree.rb 中:
Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
Spree.hooks.register('carts.upsert_items.validate', 'MyStore::CheckPurchaseLimit')
# A block works for one-liners (but can't be deduplicated or unregistered by name)
Spree.hooks.register('orders.cancel.after_cancel') do |workflow|
MyStore::Analytics.track(:order_canceled, workflow.order.number)
end
- 键的格式为
'<workflow key>.<hook>'。工作流键根据类名生成:Spree::Carts::AddItem→carts.add_item。 - 处理器以类名字符串的形式存储;钩子触发时才会将其转换为常量,因此对启动和重新加载都安全。重复注册同一个类不会产生影响。
- 时机:
Spree.hooks是进程范围的注册表,核心代码不会重置它,因此在初始化器中进行顶层注册是正确的(与payment_methods等注册表不同,核心代码会重新赋值这些注册表,必须在after_initialize中追加)。不要在config.to_prepare中注册——它会在每次开发环境重新加载时运行,导致块处理器重复。只有在注销核心代码注册的处理器时,才需要使用Rails.application.config.after_initialize(见下文)。 - 处理器可以是任何实现
#call(workflow)的类。它接收工作流实例:每个#perform关键字参数都是一个公开读取器(workflow.cart、workflow.variant、workflow.quantity),此外还有工作流通过attr_reader暴露的内容(workflow.line_item、workflow.order、workflow.fulfillment……)。请查看工作流源码 /references/hooks.md,了解每个钩子会填充哪些读取器——例如,在carts.add_item.validate期间,line_item为 nil。 - 启用
eager_load时(生产环境,通常也包括 CI),会在启动时验证键:拼写错误会引发Spree::Hooks::UnknownHookError,并列出有效钩子。在开发环境(延迟加载)中,错误的键不会报错,只会导致钩子永远不触发——运行Rails.application.eager_load!后,在控制台中运行Spree.hooks.validate!进行检查。 Spree.hooks.unregister(key, 'Handler')会移除一个处理器;unregister(key)会移除该键对应的所有处理器。
module MyStore
class CheckPurchaseLimit
def call(workflow)
return if (workflow.quantity || 1).to_i <= 10 # carts.add_item quantity defaults to nil (=1)
workflow.errors.add(:quantity, :purchase_limit_exceeded,
message: 'You can order at most 10 of this item.')
workflow.reject!
end
end
end
workflow.errors 是一个 ActiveModel::Errors。reject!(或 reject!('message'),这是在 :base 上添加错误的简写)会中止流程、撤销补偿操作、回滚尚未结束的事务,并返回失败结果。API 会像处理模型验证失败一样准确呈现该错误——返回 422 validation_error,并在 details 中按字段列出错误,因此 SDK 客户端无需特殊处理。建议使用符号类型(:purchase_limit_exceeded)并传入 message:,这样客户端可以根据错误代码进行分支处理。
carts.add_item 会在数量上累加;carts.upsert_items 会设置数量(编辑数量、移除商品、批量载荷)。购物车规则应同时应用于这两个键——读取器(cart、variant、quantity)相同,因此一个处理器类可以注册到两个键上。在店面中,upsert_items 被拒绝时会跳过该商品,并在购物车的 warnings 中报告,而不是让整个批次失败;后台订单编辑则会使整个编辑操作失败。
三种钩子类型
| 类型 | 名称 | 运行时机 | 返回值 | 多个处理器 |
|---|---|---|---|---|
| 验证 | validate(以及 orders.cancel.before_cancel) | 在写入任何内容之前——拒绝不会产生任何代价 | 忽略;使用 reject! 否决 | 按注册顺序运行;第一个 reject! 会停止流程,后续验证器不会运行 |
| 上下文 | set_promotion_context、set_tax_line_context、get_provider_data、extend_payload | 计算之前 | 返回一个 Hash——在各处理器之间进行(深度)合并,并传递给计算过程 | 全部运行;键冲突时由最后注册的处理器胜出,并通过 Rails.error 报告 |
| 生命周期 | before_* / after_*(after_item_added、before_finalize、after_cancel、before_capture……) | 在工作执行前后或执行后运行;有些会在流程事务内部运行 | 忽略 | 全部按顺序运行 |
# Context hook: contribute tax-exemption evidence to totals recalculation
module MyStore
class TaxExemption
def call(workflow)
cert = MyStore::ExemptionLookup.for(workflow.cart.customer)
cert ? { exemption_certificate: cert.number } : {}
end
end
end
Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyStore::TaxExemption')
合并后的 Hash 会以 context: 的形式传递给税务提供方;对于促销,它会作为 workflow.promotion_context 暴露(carts.recalculate.set_promotion_context)。
必须针对多处理器设计的规则
- 假设钩子上始终不只有你。 你的应用、扩展和 Spree 自身都会注册处理器(核心会在
returns.create.validate和exchanges.create.validate上注册Spree::Returns::EligibilityValidator)。 - 不要依赖处理器之间的顺序。 顺序 = 注册顺序(Gem 初始化器的加载顺序)。如果两部分必须按顺序执行,请将它们放在同一个处理器中。
- 处理器彼此不隔离。 异常会从工作流中冒出,跳过后续处理器,并回滚尚未提交的事务 —— 在
after_item_added中调用raise意味着商品从未被添加。对于可选工作,请在内部捕获异常,或将其移至事件订阅器。 - 仅从
validate拒绝。carts.complete.before_finalize在信用卡扣款之后运行 —— 在此处拒绝会回滚订单,但扣款仍然有效。 - 不要在事务中的生命周期钩子中执行耗时工作(
after_item_added、after_cancel、状态工作流中的after_*)。网络调用会在往返期间占用数据库连接;请改用订阅器。
替换 Spree 自行注册的钩子
核心的退货期限验证器由引擎初始化器注册,该初始化器会在你的 config/initializers/* 之前运行,因此在初始化器中直接调用 unregister 会将其移除:
# config/initializers/spree.rb
Spree.hooks.unregister('returns.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.unregister('exchanges.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.register('returns.create.validate', 'MyStore::ReturnPolicy')
Spree.hooks.register('exchanges.create.validate', 'MyStore::ReturnPolicy')
最有用的钩子点
| 键 | 类型 | 时机 |
|---|---|---|
carts.add_item.validate | 验证 | 构建订单项之前(事务外) |
carts.add_item.after_item_added | 生命周期 | 保存并重新计算后,事务内(workflow.line_item、line_item_created) |
carts.upsert_items.validate / .after_items_upserted | 验证 / 生命周期 | 应用每个订单项之前 / 批量操作之后 |
carts.merge.validate / .after_merge | 验证 / 生命周期 | 访客购物车→客户购物车合并 |
carts.recalculate.set_promotion_context | 上下文 | 评估促销之前 |
carts.recalculate.after_recalculate | 生命周期 | 重新定价之后 |
carts.recalculate_totals.set_tax_line_context | 上下文 | 估算税费之前 |
carts.complete.validate | 验证 | 通过结账要求后、订单创建前——阻止结账的位置(信用额度、需要审批) |
carts.complete.before_finalize / .after_finalize | 生命周期 | 付款后、订单下单前 / 后(workflow.order、order_group) |
orders.cancel.before_cancel / .after_cancel | 验证 / 生命周期 | 否决取消 / 同时写入数据(事务内) |
payments.capture.validate | 验证 | 欺诈拦截——调用支付网关之前 |
payments.process.validate | 验证 | 授权 / 购买请求发送到支付网关之前 |
refunds.create.validate | 验证 | 创建退款记录之前(审批阈值) |
fulfillments.create.get_provider_data | 上下文 | 承运商账户 / 服务级别 / 海关数据 |
fulfillments.fulfill.validate / .after_fulfill | 验证 / 生命周期 | 标记为已发货之前 / 之后 |
returns.create.validate | 验证 | 退货资格(替换核心的期限规则) |
customers.create.validate | 验证 | 注册策略(机器人筛查、B2B 审批) |
customers.anonymize.validate | 验证 | 拒绝 GDPR 擦除请求(未结争议、法律保全) |
products.activate.validate | 验证 | 产品上线前要求提供图片 / 价格 / 类别 |
products.create/update.validate | 验证 | 对 Admin API、CSV 导入和种子数据一视同仁 |
Admin 草稿订单编辑使用成对工作流,具有各自的键并支持相同的钩子:orders.add_item、orders.upsert_items、orders.recalculate、orders.recalculate_totals。店面购物车请注册购物车键,Admin 编辑请注册订单键,也可以两者都注册。成对工作流会将 order: 转发为 cart:,因此处理程序在任一键下都读取 workflow.cart。
完整目录(所有工作流、钩子、依赖键和读取器): references/hooks.md。
在运行时检查(无需 rake 任务——使用控制台):
Spree.hooks.workflows # { 'carts.add_item' => 'Spree::Carts::AddItem', ... }
Spree.hooks.carts # { 'carts.add_item' => [:validate, :after_item_added], ... }
Spree::Carts::Complete.declared_hooks # [:validate, :before_finalize, :after_finalize]
Spree.hooks.keys # keys with registered handlers
Spree.hooks.handler_count('carts.complete.validate')
Spree.hooks.validate! # true, or raises UnknownHookError
编写自己的工作流
仅为包含外部调用、补偿机制或扩展点的新多步骤操作编写工作流。普通 CRUD 应保留为服务(或模型保存操作)。
# server/app/workflows/my_store/subscriptions/renew.rb
module MyStore
module Subscriptions
class Renew < Spree::Workflow
hooks :validate, :after_renew # declare every run_hooks name
attr_reader :order # derived state handlers may read
# The signature IS the contract. Bare `super` turns each keyword into a reader.
def perform(subscription:, renewed_at: nil)
super
step :ensure_renewable
run_hooks :validate
ApplicationRecord.transaction do
step :build_order, on_flow_failure: :discard_order
step :extend_period
end
external_step :charge_customer # refuses to run inside a transaction this workflow opened
run_hooks :after_renew
subscription.publish_event('subscription.renewed')
success(order)
end
private
def ensure_renewable
failure(subscription, :not_active) unless subscription.active? # raises — aborts the flow
end
def build_order
@order = MyStore::Subscriptions::BuildOrder.call(subscription: subscription).value
end
def extend_period
subscription.update!(renews_at: (renewed_at || Time.current) + 1.month)
end
def charge_customer
Spree.payment_capture_workflow.call(payment: order.payments.last) # a failure Result fails this step
end
def discard_order # undo — armed when the transaction commits, run in reverse on later failure
order&.destroy
end
end
end
end
| 词项 | 含义 |
|---|---|
step :name | 运行私有方法。返回的失败 Result 会使流程失败。 |
step :name, with: -> { Spree.some_workflow } | 委托给可替换的协作者;其关键字参数取自此工作流的读取器 |
on_flow_failure: :undo | 步骤的补偿操作;如果后续步骤失败,则按相反顺序运行 |
external_step :name | 网络/网关 I/O;在工作流开启的事务中引发 Spree::Workflow::ContractError |
run_hooks :name | 分发已声明的钩子;返回合并后的 Hash(上下文钩子) |
failure(value, error) | 从任意位置中止(内部会引发异常,因此已开启的事务会回滚) |
workflow.reject! | 面向处理器的 failure 对应方法,会携带 errors |
halt!(value) | 成功提前退出——不允许在工作流开启的事务中使用 |
hooks :a, :b / workflow_key 'x.y' | 声明扩展点/覆盖派生键 |
规则:网络调用绝不与数据库事务共用(external_step 会强制执行此规则);failure/halt! 会引发 Exception 子类,因此 #perform 中的普通 rescue 不会捕获它们——也不要 rescue Exception。在 #perform 中,参数名是一个局部变量,会遮蔽其读取器;应为派生状态使用不同的名称(输入为 variant,输出为 line_item)。
状态(取代状态机)
class MyStore::Subscription < Spree.base_class
include Spree::HasStatus
has_status :trialing, :active, :paused, :canceled, default: :trialing
end
# => inclusion validation, #paused?, .paused scope, .with_status(:active, :trialing)
Spree::GiftCard.add_status(:on_hold, after: :active) # extend a core model's statuses
没有转换图、事件或回调——在不同状态间移动记录属于工作流(MyStore::Subscriptions::Pause.call(...),而不是 subscription.pause!)。自定义状态需要你自己的工作流来将记录转换为该状态;核心工作流会针对核心状态进行校验,而 add_status 仅会添加状态。Spree 的转换也应通过其工作流调用:Spree.product_archive_workflow.call(product: product)。
可观测性
每次运行、步骤和钩子分发都会发出 ActiveSupport::Notifications:
| 事件 | 载荷 |
|---|---|
perform.spree_workflow | workflow、outcome(success/failure/error) |
step.spree_workflow | workflow、step、external、outcome |
hooks.spree_workflow | workflow、hook、handler_count |
ActiveSupport::Notifications.subscribe('step.spree_workflow') do |*, payload|
Rails.logger.info("#{payload[:workflow]}##{payload[:step]} #{payload[:outcome]}")
end
使用 spree_opentelemetry gem(以及 OTEL_EXPORTER_OTLP_ENDPOINT)后,这些事件会自动转换为 span:每次工作流运行对应一个,每个步骤对应一个(external_step 对应 client span),每次包含处理器的钩子分发也对应一个。
测试
处理器是普通类——使用真实的工作流实例对其进行单元测试,并通过工作流进行集成测试:
RSpec.describe MyStore::CheckPurchaseLimit do
let(:cart) { create(:cart) }
let(:variant) { create(:variant) }
let(:handler) { 'MyStore::CheckPurchaseLimit' }
before { Spree.hooks.register('carts.add_item.validate', handler) }
after { Spree.hooks.unregister('carts.add_item.validate', handler) }
it 'rejects more than 10' do
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 11)
expect(result).to be_failure
expect(result.error.value.details[:quantity]).to include(a_hash_including(error: :purchase_limit_exceeded))
expect(cart.line_items.count).to eq(0)
end
end
- 如果处理器已在
config/initializers/spree.rb中注册,它在规格测试中就已处于活动状态——无需添加before/after。 - 应用规格测试中绝不要调用
Spree.hooks.clear!——这会永久清除核心注册(退货期限验证器),并影响进程的其余部分。只注销你注册的内容。对于代码块,请保留 proc:h = ->(w) { ... }; Spree.hooks.register(key, &h)…Spree.hooks.unregister(key, h)。 - 在 CI 中添加启动时检查:
Rails.application.eager_load!; Spree.hooks.validate!。
注意事项
- 替换工作流类会改变其钩子键。
MyApp::Carts::AddItem < Spree::Carts::AddItem会分发my_app.carts.add_item.*——carts.add_item.*上的所有处理程序(你自己的、扩展中的和核心的)都会静默地停止触发。如果必须创建子类,请在其中声明workflow_key 'carts.add_item'。请参阅spree-dependencies。 - 在
orders.complete.*上注册会在启动时(急切加载期间)引发UnknownHookError——Spree::Orders::Complete未声明任何钩子。结账会经过carts.complete。 carts.recalculate_totals只有set_tax_line_context;促销上下文位于carts.recalculate。- 从作业、rake 任务或控制台运行的工作流需要商店上下文。 先设置
Spree::Current.store = cart.store(或order.store)——Spree::Store.default可能为 nil。遍历多个商店记录的作业会将每条记录分别放入Spree::Current.with_store(record.store) { … }中处理(或者在Spree::BaseJob子类中使用each_store_of(relation))。这样做还会为该作业启用开发/测试环境的Spree::StoreScopeGuard,因此你编写的处理程序中的未限定查询也会像在 API 请求中一样被标记。 - 验证钩子接收的是参数,而不是结果:在设置它们的步骤运行之前,
workflow.line_item、workflow.order、workflow.fulfillment都是 nil。
延伸阅读
node_modules/@spree/docs/dist/developer/customization/workflows.md(https://spreecommerce.org/docs/developer/customization/workflows)node_modules/@spree/docs/dist/developer/customization/validations.md—— 钩子、模型验证与验证器注册表之间的区别node_modules/@spree/docs/dist/developer/providers/observability.md—— OpenTelemetry 设置- 源码:
spree_corelib/spree/workflow.rb、lib/spree/hooks.rb、app/workflows/spree/**、app/models/concerns/spree/has_status.rb - 相关技能:
spree-dependencies(替换类)、spree-events-webhooks(事后副作用)、spree-checkout(需求注册表)、spree-customization(路由)