关于 spree-events-webhooks
spree-events-webhooks 是面向 Spree 开发者的事件与 Webhook 技能,说明如何处理 Spree 发布的生命周期和业务事件。它区分应用内订阅器与发往外部系统的 HTTPS Webhook,并涵盖注册、投递及签名验证。
主要功能
- 01区分订阅器与 Webhook
- 02介绍生命周期和业务事件
- 03说明订阅器注册与异步处理
- 04配置事件筛选与 Webhook 端点
- 05验证 Webhook 签名及去重
适用场景
- 01将已下单事件同步到 ERP
- 02由订阅器发送通知或记录状态历史
- 03向外部系统发送 Spree 事件
- 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 发布的事件
分为两类:
- 生命周期事件 —
<prefix>.created、.updated、.deleted,由声明了publishes_lifecycle_events的模型在提交后发布(包括订单、购物车、订单项、产品、变体、价格、付款、履约、库存水平、退货、客户、媒体等)。客户使用user.*前缀(user.created等)。单独执行touch不会触发.updated。 - 业务事件 — 在关键时刻显式发布:
| 事件 | 触发时机 |
|---|---|
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
调试
订阅者始终未运行
Spree.subscribers.include?(MySubscriber)— 是否已注册?- 事件名称是否准确?(应为
order.placed,而非order.complete。)查看日志:[Spree Event] order.placed | payload: …(使用Spree::Config.events_log_enabled = false关闭)。 - 是否为异步?作业工作进程是否正在运行(Solid Queue / Sidekiq),且
Spree::Events::SubscriberJob是否失败? - 测试:core 的测试套件会全局禁用事件;如果你的测试套件也如此,请为示例添加
events: true标签,或使用Spree::Events.enable { … }包装。
端点未收到任何内容
endpoint.active? && !endpoint.auto_disabled?endpoint.subscribed_to?('order.placed')- 事件是否有关联的店铺?(在作业/脚本中设置
Spree::Current.store。)端点是否属于同一家店铺? - 执行
endpoint.send_test!,然后查看endpoint.webhook_deliveries.recent.first中的response_code/request_errors。 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(测试订阅者)