mcpField
S
spree-events-webhooks

spree-events-webhooks

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

关于 spree-events-webhooks

spree-events-webhooks 是面向 Spree 开发者的事件与 Webhook 技能,说明如何处理 Spree 发布的生命周期和业务事件。它区分应用内订阅器与发往外部系统的 HTTPS Webhook,并涵盖注册、投递及签名验证。

主要功能

  1. 01区分订阅器与 Webhook
  2. 02介绍生命周期和业务事件
  3. 03说明订阅器注册与异步处理
  4. 04配置事件筛选与 Webhook 端点
  5. 05验证 Webhook 签名及去重

适用场景

  1. 01将已下单事件同步到 ERP
  2. 02由订阅器发送通知或记录状态历史
  3. 03向外部系统发送 Spree 事件
  4. 04避免数据迁移期间触发事件投递

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/spree/agent-skills --skill spree-events-webhooks

安装后在 Claude Code 中输入/spree-events-webhooks确认已加载。

附带文件references/events.md

详细介绍

Spree 事件 + Webhook

Spree 通过一个事件总线发布发生的事情(order.placed、payment.completed、fulfillment.fulfilled 等)。有两类消费者监听事件总线:

消费者所在位置用途
订阅器Spree 应用中的 Ruby 类处理你自己的副作用,例如 ERP 同步、自定义邮件、分析和审计历史
Webhook向外部 URL 发送 HTTPS POST 请求由其他系统作为消费者,例如 Next.js 店面、n8n/Zapier/Make 或合作伙伴应用

如果响应代码位于 Rails 应用之外,请使用 webhook;如果位于应用内部,请使用订阅器。

事件与工作流钩子

事件会在操作完成后触发,通常在后台运行。它们无法阻止或更改操作。如果你需要否决操作(workflow.reject!)、添加步骤,或在操作运行期间将数据传入计算,请改为注册工作流钩子:Spree.hooks.register('<flow>.<hook>', Handler) — 参见 spree-workflows 技能。

需求使用
根据业务规则阻止购物车完成、退货或取消钩子(*.validate)
在同一事务中,在工作流内部执行额外操作钩子(after_*)
将已下单的订单推送到 WMS,发送 Slack 消息订阅器
通知 Spree 之外的系统Webhook
保留状态变更历史使用订阅器写入自己的数据表

事件就是审计轨迹。 Spree 不会保存 StateChange/LogEntry 记录。每个重要状态转换都会发布事件(order.placed、order.canceled、payment.completed、payment.voided、fulfillment.fulfilled、fulfillment.delivered、fulfillment.canceled、return.* 等)。如果需要持久化历史记录,请通过订阅器写入。

Spree 发布的事件

分为两类:

  1. 生命周期事件 — <prefix>.created、.updated、.deleted,由声明了 publishes_lifecycle_events 的模型在提交后发布(包括订单、购物车、订单项、产品、变体、价格、付款、履约、库存水平、退货、客户、媒体等)。客户使用 user.* 前缀(user.created 等)。单独执行 touch 不会触发 .updated。
  2. 业务事件 — 在关键时刻显式发布:
事件触发时机
order.placed结账完成(或管理员下单了一个草稿订单)。载荷包含 notify_customer
order.paid付款金额足以覆盖订单总额
order.fulfilled / order.delivered所有履约均已交接 / 送达
order.canceled订单已取消(最终状态 — 订单无法恢复)
order.approved员工批准了高风险订单
cart.created / .updated / .deleted购物车生命周期 — 购物车是与订单分开的资源
payment.completed / .captured / .voided / .refunded付款相关时刻
payment_session.completed / .failed / .expired 等支付服务提供商会话结果(payment_setup_session.* 也有同一组事件)
fulfillment.fulfilled / .delivered / .canceled包裹已发出 / 已送达 / 已取消(fulfilled 的元数据包含 notify_customer)
product.out_of_stock / .back_in_stock / .activated / .archived商品目录的可用状态
return.*、exchange.*、claim.*售后工作流(return.received、return.refunded 等)
digital_link.downloaded客户下载了数字资产
import.completed、import_row.failed批量导入

完整列表及载荷说明见:references/events.md。权威参考文档是文档中的 webhooks-events.md(见文末)。

选择具体事件。 order.updated 会在每次修改备注时触发;order.placed 只触发一次。通过 order.updated 发送确认邮件,客户就会收到九封邮件。

重命名的事件(一个版本周期内的别名)

order.completed → order.placed,order.shipped → order.fulfilled,shipment.shipped → fulfillment.fulfilled,shipment.canceled → fulfillment.canceled,stock_item.* → stock_level.*,wished_item.* → wishlist_item.*,digital.* → digital_asset.*。旧名称会与新名称一起发布,直到 6.1 版本——因此,订阅 order.* 的订阅者会同时收到 order.placed 和 order.completed,订阅 fulfillment.* 和 shipment.* 的订阅者也会看到两个名称。请订阅新名称。只有 order.completed 带有 metadata.deprecated_alias_of;在通配符处理程序中,需显式跳过旧名称(参见审计方案)。从 5.x 升级?请参阅 spree-upgrade-5-to-6。

第 1 部分:订阅者

生成一个订阅者
spree generate subscriber OrderPlaced order.placed order.canceled   # classic: bin/rails g spree:subscriber …
# --sync       → subscribes_to …, async: false
# --skip-spec  → no spec/subscribers/… file

此命令会生成 app/subscribers/order_placed_subscriber.rb、一个测试文件,以及——这个步骤常被人忘记——config/initializers/spree.rb 中的注册行:

Rails.application.config.after_initialize do
  Spree.subscribers << OrderPlacedSubscriber
end

订阅者不会自动发现。未注册的订阅者不会执行,也不会报错。

请注册类常量。也可以使用类名字符串('OrderPlacedSubscriber'),并在启动时注册订阅者时解析;但截至 6.0.0.beta4,字符串只会在开发和测试环境中解析。在生产环境中,启动时会因 NoMethodError: undefined method 'subscription_patterns' for String 崩溃,而测试无法捕获此错误。

# app/subscribers/order_placed_subscriber.rb
class OrderPlacedSubscriber < Spree::Subscriber
  subscribes_to 'order.placed'

  def handle(event)
    order = Spree::Order.find_by_prefix_id(event.payload['id'])
    return unless order

    WarehouseClient.new.submit(order)
  end
end
  • 实现 handle(event)。不要覆盖 call——基类的 call 会路由 on 处理程序,并回退到 handle。
  • event.name、event.payload(字符串键,使用 API v3 序列化器序列化——带前缀的 ID,金额为字符串)、event.metadata(始终包含 spree_version)、event.store_id、event.id(UUID)、event.created_at。
  • 载荷仅包含记录的顶层属性;需要关联数据时,请加载记录(find_by_prefix_id)。
多个事件、通配符和路由
class PaymentSubscriber < Spree::Subscriber
  subscribes_to 'payment.completed', 'payment.voided', 'return.*'

  on 'payment.completed', :handle_complete
  on 'payment.voided',    :handle_void
  # anything without an `on` falls through to #handle

  def handle(event)
    Rails.logger.info("[returns] #{event.name}")
  end

  private

  def handle_complete(event)
    # ...
  end

  def handle_void(event)
    # ...
  end
end
异步(默认)与同步

默认情况下,每个订阅者都在 Spree::Events::SubscriberJob 中运行,并使用 Spree.queues.events 队列(除非更改,否则为 :default),因此缓慢的 API 调用不会拖慢结账流程。作业在出错时最多重试 3 次——请确保处理程序具有幂等性(检查是否已执行过操作)。

subscribes_to 'order.placed', async: false 会在事件发布进程中内联运行。同步订阅者引发的错误会通过 Rails.error 报告,且仅在开发/测试环境中重新引发——在生产环境中会被吞掉,因此不要依赖同步订阅者来中止任何操作(应使用钩子)。

发布自定义事件

任何 Spree 模型都可以发布事件:

order.publish_event('order.flagged_for_review')                     # payload = serialized order
order.publish_event('order.flagged_for_review', { 'id' => order.prefixed_id, 'score' => 87 })
Spree::Events.publish('brand.synced', { 'id' => brand.prefixed_id })  # from anywhere

自定义事件会发送给订阅者和 Webhook 端点。请在写入提交后再发布——publish_event 会立即分发事件,否则异步订阅者可能会读取未提交的数据。

自有模型的生命周期事件
module Spree
  class Brand < Spree.base_class
    publishes_lifecycle_events                     # brand.created / .updated / .deleted
    # publishes_lifecycle_events only: [:create, :delete]
    # publishes_lifecycle_events except: [:update]
    # self.event_prefix = 'brand'                  # override the prefix if needed
  end
end

如果存在 Spree::Api::V3::BrandSerializer(由 spree:api_resource 生成器创建),载荷会使用它;否则使用最简格式 { id, created_at, updated_at }。.deleted 会在销毁前捕获载荷。

静默事件
Spree::Events.disable { bulk_fix! }                 # nothing published in the block
Spree::Events.disable_lifecycle { import_rows! }    # suppress *.created/updated/deleted only

在数据迁移和回填中使用,避免 Webhook 消息泛滥。

商店上下文很重要

每个事件都会记录来自 Spree::Current.store 的 store_id。Webhook 投递会跳过没有商店的事件,且端点只会接收来自所属商店的事件。在作业、rake 任务和控制台脚本中,请显式设置:

Spree::Current.store = order.store
order.publish_event('order.flagged_for_review')

第 2 部分:Webhook(出站 HTTPS)

event published ─▶ Spree::WebhookEventSubscriber (subscribes to '*', ships with spree_api)
  ─▶ for each enabled Spree::WebhookEndpoint in event.store_id that subscribed_to?(name)
  ─▶ Spree::WebhookDelivery (whd_…) ─▶ Spree::WebhookDeliveryJob (Spree.queues.webhooks)
  ─▶ Spree::Webhooks::DeliverWebhook: signed POST, 30s timeout, result recorded
创建端点

控制台:Settings → Developer → Webhooks。或者使用 Admin API:

spree api post /webhook_endpoints -d '{
  "name": "ERP sync",
  "url": "https://erp.example.com/spree-webhooks",
  "subscriptions": ["order.placed", "order.canceled", "fulfillment.*"],
  "active": true
}'
const endpoint = await admin.webhookEndpoints.create({
  url: 'https://erp.example.com/spree-webhooks',
  subscriptions: ['order.placed', 'fulfillment.*'],
})
endpoint.secret_key // 64-char hex — returned ONLY on create; store it now
  • subscriptions:精确名称、通配符(order.*),或 "*"/空值 = 全部。
  • 端点属于当前店铺(从请求中设置,绝不通过参数传入)。
  • 端点 ID 为 whe_…,投递 ID 为 whd_…。
  • spree_api 配置:SPREE_WEBHOOKS_ENABLED(全局紧急停用开关)、SPREE_WEBHOOKS_VERIFY_SSL(在开发环境之外默认开启)。
  • 管理员和卖家用户的密码重置事件永远不会投递,即使目标是 * 端点也一样。
  • customer.password_reset_requested 携带有效的重置令牌,因此属于 Spree::WebhookEndpoint::CREDENTIAL_EVENTS:只有明确按名称列出该事件的端点才会收到它——*、空订阅列表或 customer.* 之类的模式都不行。创建一个明确列出该事件的端点,或更改此类端点的 URL/订阅,都需要额外具备 write_customers 权限(否则返回 403)。
  • 客户记录会发布 user.created / user.updated / user.deleted(以及 customer.anonymized、customer.password_reset*)——没有 customer.created。
信封与标头
POST /spree-webhooks
Content-Type: application/json
User-Agent: Spree-Webhooks/1.0
X-Spree-Webhook-Signature: <hex hmac-sha256>
X-Spree-Webhook-Timestamp: 1767225600
X-Spree-Webhook-Event: order.placed

{ "id": "<event uuid>", "name": "order.placed", "created_at": "…",
  "data": { "id": "or_m3Rp9wXz", "number": "R123456789", "total": "129.99", "notify_customer": true, … },
  "metadata": { "spree_version": "6.0.0" } }

id 是事件 UUID——用它在接收端去重。

验证签名

使用端点的 secret_key 对 "#{timestamp}.#{raw_body}" 计算 HMAC-SHA256。

def verified?(request)
  ts   = request.headers['X-Spree-Webhook-Timestamp'].to_i
  sig  = request.headers['X-Spree-Webhook-Signature'].to_s
  body = request.raw_post
  return false if (Time.current.to_i - ts).abs > 300   # replay window

  expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('SPREE_WEBHOOK_SECRET'), "#{ts}.#{body}")
  ActiveSupport::SecurityUtils.secure_compare(expected, sig)
end
import crypto from 'node:crypto'

export function verified(rawBody: string, ts: string, sig: string, secret: string) {
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')
  return expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}

请针对原始请求体进行验证(重新序列化的 JSON 无法匹配),始终使用时序安全比较,并始终检查时间戳。在 Next.js 路由处理程序中,解析前先读取 await req.text()。

失败、重新投递与自动停用
  • 非 2xx 响应、超时或连接错误会将投递标记为失败(记录 response_code、response_body、error_type、request_errors、execution_time),并由 Spree::WebhookDeliveryJob 通过退避机制重试(retry_on …, wait: :polynomially_longer, attempts: 5):总计尝试 5 次,约持续 6 分钟;每次都会覆盖同一条投递记录的结果(日志显示最近一次尝试)。端点一旦被关闭(手动或自动停用),重试就会停止;重试耗尽后不会再次抛出到队列后端。每次尝试都携带相同的事件 id——请据此去重。
  • 重试耗尽后手动重新发送:控制台投递日志、delivery.redeliver!、POST /api/v3/admin/webhook_endpoints/:id/deliveries/:id/redeliver,或 admin.webhookEndpoints.deliveries.redeliver(whe, whd)。
  • 连续 15 次投递失败后(Spree::WebhookEndpoint::AUTO_DISABLE_THRESHOLD;按投递记录计数,而不是按重试次数计数),端点会被停用(active: false,并设置 disabled_at),工作人员会收到 Spree::WebhookMailer.endpoint_disabled。可通过 endpoint.enable!、PATCH …/webhook_endpoints/:id/enable,或在控制台将 active: true 设为启用——这些操作都会清除停用状态。
  • 投递日志不等同于网络传输的载荷。 凭据会在写入 spree_webhook_deliveries.payload 前被脱敏,并仅在发送时于内存中重新附加(Spree::WebhookPayloadRedaction):token(访客购物车令牌)、reset_token、unsubscribe_token、verification_token、download_url、支付会话客户端密钥,以及礼品卡的 code。接收方会收到真实值(包括自动重试);日志和 Admin API 显示 [REDACTED]。手动重新投递会重新发送存储的载荷,因此这些字段会以 [REDACTED] 的形式到达——不要依赖重新投递密码重置或支付会话事件。
  • Admin API 仅当调用方有权读取事件所涉及的记录时,才会返回投递的 payload(delivery.payload_permission_key,例如 order.* 需要 read_orders,customer.* / user.* 及任何未映射的主体需要 read_customers);否则 payload 为 null,且只显示状态/响应。
  • 如需确保投递成功,请让接收方快速响应(先返回 2xx 确认,再将任务加入队列),并定期通过 Admin API 对账。
测试端点
endpoint.send_test!   # queues a synthetic 'webhook.test' delivery

或者使用 POST /api/v3/admin/webhook_endpoints/:id/send_test / 仪表板中的“发送测试”按钮。

SSRF + 额外标头

在开发环境之外,解析到私有地址或环回地址的端点 URL 会在保存时被拒绝,请求也会经过 ssrf_filter。在开发环境中,localhost / host.docker.internal 可以使用。若要添加出站标头(例如,追踪传播),请在 config.to_prepare 中追加一个可调用对象:

Spree::Webhooks::DeliverWebhook.header_decorators << ->(headers, delivery) { headers['X-Env'] = Rails.env }

配方

持久化审计跟踪

class OrderAuditSubscriber < Spree::Subscriber
  subscribes_to 'order.*', 'payment.*', 'fulfillment.*'
  LEGACY_ALIASES = %w[order.completed order.shipped].freeze

  def handle(event)
    return if LEGACY_ALIASES.include?(event.name)        # 6.0 dual-emitted old names
    AuditEntry.create_or_find_by!(event_id: event.id) do |e|
      e.name = event.name
      e.resource_id = event.payload['id']
      e.payload = event.payload
    end
  end
end

支付会话失败时通知 Slack

class PaymentFailureSubscriber < Spree::Subscriber
  subscribes_to 'payment_session.failed'

  def handle(event)
    Slack.notify("Payment session #{event.payload['id']} failed")
  end
end

调试

订阅者始终未运行

  1. Spree.subscribers.include?(MySubscriber) — 是否已注册?
  2. 事件名称是否准确?(应为 order.placed,而非 order.complete。)查看日志:[Spree Event] order.placed | payload: …(使用 Spree::Config.events_log_enabled = false 关闭)。
  3. 是否为异步?作业工作进程是否正在运行(Solid Queue / Sidekiq),且 Spree::Events::SubscriberJob 是否失败?
  4. 测试:core 的测试套件会全局禁用事件;如果你的测试套件也如此,请为示例添加 events: true 标签,或使用 Spree::Events.enable { … } 包装。

端点未收到任何内容

  1. endpoint.active? && !endpoint.auto_disabled?
  2. endpoint.subscribed_to?('order.placed')
  3. 事件是否有关联的店铺?(在作业/脚本中设置 Spree::Current.store。)端点是否属于同一家店铺?
  4. 执行 endpoint.send_test!,然后查看 endpoint.webhook_deliveries.recent.first 中的 response_code / request_errors。
  5. SPREE_WEBHOOKS_ENABLED 未设置为 false;作业工作进程正在运行。

签名不匹配 — 使用了解析后的请求体而非原始请求体、使用了错误端点的密钥,或者时钟偏差超出了允许范围。

延伸阅读

  • 事件:node_modules/@spree/docs/dist/developer/core-concepts/events.md
  • Webhook:node_modules/@spree/docs/dist/developer/core-concepts/webhooks.md
  • 所有事件和负载:node_modules/@spree/docs/dist/api-reference/webhooks-events.md(https://spreecommerce.org/docs/api-reference/webhooks-events)
  • 源码:Spree::Subscriber、Spree::Publishable、Spree::Events、Spree::WebhookEndpoint、Spree::WebhookDelivery、Spree::Webhooks::DeliverWebhook、Spree::WebhookEventSubscriber
  • 相关技能:spree-workflows(钩子)、spree-security(Webhook 接收端)、spree-testing(测试订阅者)

评论

支持 Markdown