mcpField
S
spree-workflows

spree-workflows

github.com/spree开发10104 次安装4 周前更新
代码仓库

关于 spree-workflows

spree-workflows 是介绍 Spree 服务与工作流架构的开发者技能,涵盖扩展钩子和结果处理,帮助团队在合适位置加入业务逻辑,而不必修补核心操作。

主要功能

  1. 01服务与工作流的职责划分
  2. 02验证、上下文和生命周期钩子
  3. 03钩子注册与拒绝处理
  4. 04工作流扩展点目录
  5. 05事件与替换操作的使用指南

适用场景

  1. 01根据资格规则阻止结账
  2. 02为税费或促销计算添加上下文
  3. 03在生命周期钩子中写入关联记录
  4. 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:

层级所在位置适用场景
Serviceapp/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_workflowworkflow、outcome(success/failure/error)
step.spree_workflowworkflow、step、external、outcome
hooks.spree_workflowworkflow、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_core lib/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(路由)

评论

支持 Markdown