- 电商
- 后端
- 前端
【免费下载链接】solidus
🛒 Solidus, the open-source eCommerce framework for industry trailblazers.
导读:Solidus Legacy Promotions 是一个将经典 Spree/Solidus 促销系统从 Solidus 各 Gem 中抽取出来的独立 Rails Engine,为订单、行项目与运费提供一套"规则 + 动作"驱动的灵活折扣能力。本文将先从四大核心概念(促销处理器、促销规则、促销动作、调整项)拆解其运行原理,再以一个"使用指定支付方式即可享受 50% 运费折扣"的真实需求为线索,完整演示自定义 handler、rule、action 并接入后台管理的全部步骤,让你既能看懂现有促销代码,也能照此模式扩展出属于自己的促销逻辑。
一、认识 Legacy Promotions:安装方式与项目定位
Solidus Legacy Promotions(Gem 名为solidus_legacy_promotions)承载的是 Solidus 历史上经典的那套基于规则(rule)与动作(action)的促销系统。正如 legacy_promotions/README.md 所述,它被从 Solidus 其他 Gem 中抽取出来,作为一个独立的 Rails Engine 维护。
1.1 安装
安装方式取决于你的 Gemfile 结构:
- 如果你的 Gemfile 中包含
gem "solidus"这一行,本 Gem 会随 Solidus 全套自动安装,无需额外操作; - 如果你是按需引入 Solidus 套件中的各个独立 Gem,则需要显式添加:
gem "solidus_legacy_promotions"1.2 定位与演进方向
该 Gem 的命名已经暗示了它的处境——它计划被弃用(slated for deprecation)。Solidus 团队正在推进一套全新的促销实现,并计划在同一年内随版本发布。在本仓库中,这套新实现对应 promotions/ 目录下的solidus_promotionsGem。
从引擎的初始化代码可以印证两者的衔接逻辑:在 engine.rb 中,只有当Spree::Config.promotions还是空的NullPromotionConfiguration(即商店没有使用新的solidus_promotionsGem)时,引擎才会将配置切换为SolidusLegacyPromotions::Configuration,并注入order_contents_class、adjustment_promotion_source_types等相关设置。也就是说,两套促销系统通过配置层隔离,互不抢占。
另外,该引擎还负责向后端注入促销管理入口:在SolidusSupport.backend_available?时向后台菜单插入"Promotions / Promotion Categories"菜单项(见 engine.rb),在SolidusSupport.admin_available?时向 Solidus Admin 注册对应的菜单与订单列表组件(engine.rb)。
二、架构全景:四大核心概念如何协同工作
Legacy Promotions 之所以能覆盖"整单折扣、单行项目/多行项目折扣、运费折扣"等多种场景,是因为它把促销能力拆解为四个相互协作的概念:
| 概念 | 职责 | 仓库内代表实现 |
|---|---|---|
| Promotion handlers(促销处理器) | 在顾客旅程的合适节点激活促销 | legacy_promotions/app/models/spree/promotion_handler |
| Promotion rules(促销规则) | 校验订单是否满足促销条件 | legacy_promotions/app/models/spree/promotion/rules |
| Promotion actions(促销动作) | 定义对合格订单施加的具体折扣 | legacy_promotions/app/models/spree/promotion/actions |
| Adjustments(调整项) | 持久化折扣信息,并在订单每次更新时重算 | legacy_promotions/app/models/spree/promotion/order_adjustments_recalculator.rb |
[!NOTE] 调整项并不只服务于促销。任何会修改订单金额的概念(例如税费)都会使用调整项机制,促销只是它的典型应用之一。
2.1 免费运费示例:一次完整的促销生命周期
原文档用"订单总额达到 100 美元即免运费"这个例子,展示了 Solidus 处理一次促销的完整流程:
- 顾客填写配送信息时,Shipping 促销处理器 在订单上激活促销。从源码看,它先合并"已关联订单的促销"与"自动应用(
apply_automatically: true)的促销",逐一调用promotion.eligible?(order)与promotion.activate(order:); - 激活时,促销先执行基础资格检查(使用次数上限、有效日期区间等),再校验定义的规则是否全部满足。这两步分别对应 promotion.rb 中的 eligible? 方法:先依次检查
inactive?、blacklisted?、usage_limit_exceeded?、promotion_code.usage_limit_exceeded?,最后调用eligible_rules逐条验证规则; - 调用规则时,ItemTotal 规则 确认订单
item_total是否达到 100 美元(其默认preferred_amount正是100.00,且支持gt/gte两种运算符); - 订单合格后,FreeShipping 动作 被应用到订单的 shipments 上——它遍历每个 shipment,为尚未被该动作优惠过的 shipment 创建一条
amount = shipment.cost * -1的调整项,从而抵消运费; - 顾客获得免费运费。
2.2 激活的底层调用链
促销激活的核心逻辑集中在 promotion.rb 中的 activate 方法:
- 先通过
self.class.order_activatable?(order)排除complete、awaiting_return、returned等不可再激活的订单状态; - 组装 payload(含
order、promotion、line_item、user、path、promotion_code),依次调用每个动作的action.perform(payload); - 只要任何一个动作返回
true,就通过order.order_promotions.find_or_create_by!建立订单与促销的关联,并返回action_taken。
2.3 调整项重算:促销资格的动态保持
文档特别强调:促销调整项在订单每次更新时都会重算,以确认订单状态变化后资格是否仍然成立。这一机制由 OrderAdjustmentsRecalculator 实现:
- 遍历所有行项目与 shipments 上的促销调整项,逐个调用
recalculate; recalculate会跳过已 finalize 的调整项,并根据调整项的 source(即对应的 PromotionAction)重新调用compute_amount算出金额,再通过adjustment.source.promotion.eligible?重新判断资格;- 计算完成后,由 PromotionChooser 在同一组促销调整项中挑选金额最低(优惠最大)的一条作为最佳促销,其余标记为 ineligible——这正是"整单只能叠加一个最优整单促销"的规则来源;
- 该重算器在
Spree::OrderUpdater中于税费计算之前被调用,从而保证税费始终尊重促销结果。
[!TIP] 文档提到可以对重算行为做定制。对应的扩展点是
Spree::Config.promotions.order_adjuster_class(默认指向Spree::Promotion::OrderAdjustmentsRecalculator),详见下文"配置化扩展点"一节。
2.4 开箱即用的 handler、rule 与 action 清单
Legacy Promotions 自带一批实用的内置实现,可以从两个层面确认:
- 处理器(4 个):目录 legacy_promotions/app/models/spree/promotion_handler 下有
Cart(商品入购物车时激活,兼容行项目级促销)、Coupon(优惠码应用/移除,负责处理coupon_code_not_found、coupon_code_max_usage等状态码)、Page(通过促销的path字段按访问路径激活)、Shipping(配送环节激活运费类促销)。其中 Coupon 处理器 的apply/remove方法完整演示了"校验 → 激活 → 重算"以及错误状态码的设定流程; - 规则(13 个):
ItemTotal、Product、User、FirstOrder、UserLoggedIn、OneUsePerUser、Taxon、MinimumQuantity、NthOrder、OptionValue、FirstRepeatPurchaseSince、UserRole、Store,默认注册于 configuration.rb 中的 rules 类集合; - 动作(4 个):
CreateAdjustment(整单折扣,基于 calculator)、CreateItemAdjustments(按行项目折扣)、CreateQuantityAdjustments(按数量折扣)、FreeShipping(免运费),默认注册于 configuration.rb 中的 actions 与 shipping_actions 类集合。其中shipping_actions专门标识"运费类动作",被 Shipping 处理器 的active_shipping_promotions查询所使用。
三、实战:三步实现"指定支付方式享 50% 运费折扣"
文档用一个极具代表性的需求串联起整套自定义流程:
我们想与新的支付平台建立合作关系:顾客在结账时使用该支付方式,即可享受 50% 的运费折扣。
为此需要分别实现我们自己的 handler、rule 和 action。下面按原文档的路线完整走一遍。
3.1 实现一个新的 handler(促销处理器)
促销处理器在技术上"没什么特别":它们就是普通的 Ruby 对象,在结账流程的正确位置被创建并调用。Solidus 并没有为 handler 定义统一 API,但可以参考 现有处理器 的格式来写。原文档给出的实现如下:
# frozen_string_literal: true module AmazingStore module PromotionHandler class Payment RULES_TYPE = 'AmazingStore::Promotion::Rules::Payment' attr_reader :order def initialize(order) @order = order end def activate promotions.each do |promotion| promotion.activate(order: order) if promotion.eligible?(order) end end private def promotions ::Spree::Promotion. active. joins(:promotion_rules). where('promotion_rules.type' => RULES_TYPE) end end end end这个 handler 先按promotion_rules.type筛选出包含我们特定规则类型的、处于激活状态的促销子集,再逐个调用promotion.eligible?(order)与promotion.activate(order:)激活合格的促销。
为什么需要自己的 handler?文档点出了一个关键事实:其他内置 handler 虽然也可能"捡到"我们的促销,但如果在支付步骤之前运行,它们根本无法激活它(比如Cart处理器在商品加入购物车时就运行了)。所以我们要确保促销在支付方式被选中之后的节点被激活。
文档给出的做法是:利用Spree::Order的状态机,在结账流程从:payment状态迁出之后,通过prepend注入一个回调:
# frozen_string_literal: true module AmazingStore module LoadPaymentPromotionHandler def self.prepended(base) base.state_machine.after_transition(from: :payment) do |order| AmazingStore::PromotionHandler::Payment.new(order).activate end end ::Spree::Order.prepend(self) end end这与 Solidus 自身的做法一脉相承:订单状态机定义在 core/app/models/spree/core/state_machines/order/class_methods.rb,其中已有after_transition from: any - :cart, to: any - [:confirm, :complete] do |order| order.recalculate end之类的全局回调;而 Legacy Promotions 自己也通过 spree_order_state_machine_patch.rb 用同样的prepend手法,在:delivery状态迁出前调用apply_shipping_promotions。可见"prepend 状态机模块 + 挂载 transition 回调"正是 Solidus 扩展结账行为的标准姿势。
3.2 实现一个新的 rule(促销规则)
有了 handler,接下来实现检查"顾客是否使用了被推广的支付方式"的促销规则。
为了让商店管理员能在后台编辑"哪个支付方式享受折扣",最优雅的方式是为规则本身创建 preference:
# frozen_string_literal: true module AmazingStore module Promotion module Rules class Payment < ::Spree::PromotionRule DEFAULT_PREFERRED_PAYMENT_TYPE = 'AmazingStore::AmazingPaymentPlatform' ALLOWED_PAYMENT_TYPES = [ DEFAULT_PREFERRED_PAYMENT_TYPE, 'Spree::PaymentMethod::Check', 'Spree::PaymentMethod::CreditCard' ].freeze preference :payment_type, :string, default: DEFAULT_PREFERRED_PAYMENT_TYPE validates :preferred_payment_type, inclusion: { in: ALLOWED_PAYMENT_TYPES, allow_blank: true }, on: :update def applicable?(promotable) promotable.is_a?(::Spree::Order) end def eligible?(order, _options = {}) order.payments.any? do |payment| payment.payment_method.type == preferred_payment_type end end end end end end对照 PromotionRule 基类 可以理解这套契约:基类通过raise NotImplementedError强制子类实现applicable?和eligible?,并默认提供actionable?(返回true,表示可应用于任意行项目,子类可按需覆写)与eligibility_errors(用ActiveModel::Errors记录不合格原因,供后台展示错误码)。此外基类还带有一个值得注意的校验unique_per_promotion:同一个促销内不允许出现两条同类型的规则。
[!CAUTION] 你可能注意到我们允许支付类型在创建时为空。这是因为促销规则在创建之初是不带任何 preference 的——这样后台才能先展示正确的表单给管理员配置。如果从一开始就强制要求支付类型必填,Solidus 将无法创建这条规则,管理员会直接看到报错。这也是为什么
inclusion校验放在on: :update上的原因。
为规则提供后台 UI。规则名是Payment,因此需要创建对应的 partial,其中可以通过局部变量promotion_rule访问当前规则实例:
<div class="row"> <div class="col-6"> <div class="field"> <%= promotion_rule.class.human_attribute_name(:payment_type) %> </div> </div> <div class="col-6"> <div class="field"> <%= select_tag "#{param_prefix}[preferred_payment_type]", options_for_select(promotion_rule.class::ALLOWED_PAYMENT_TYPES, promotion_rule.preferred_payment_type), class: 'fullwidth' %> </div> </div> </div>partial 的命名与存放位置并非随意——PromotionRule#to_partial_path 返回"spree/admin/promotions/rules/#{model_name.element}",即后台渲染规则表单时正是按规则类名去app/views/spree/admin/promotions/rules/下寻找同名的_xxx.html.erb。仓库内置规则的视图也遵循同一约定,例如 _item_total.html.erb 通过promotion_rule.class.operator_options渲染运算符下拉框、通过fields_for param_prefix, promotion_rule渲染金额与币种输入。
注册规则。最后在 initializer 中把规则注册进spree.promotions.rules类集合:
# ... Rails.application.config.spree.promotions.rules << 'AmazingStore::Promotion::Rules::Payment'这套注册机制的后台消费逻辑在 PromotionRulesController:validate_promotion_rule_type会从Spree::Config.promotions.rules中按type名称查找对应类,找不到就提示invalid_promotion_rule。也就是说,只有注册过的规则才会出现在后台的规则选择列表中。
补充友好描述。为了让规则表单更易读,可以关联一段描述文本:
en: # ... activerecord: attributes: amazing_store/promotion/rules/payment: description: Must use the specified payment method完成以上步骤后,在后台新建促销时,规则列表里就会出现Payment规则,并且描述会随表单一起渲染。
3.3 实现一个新的 action(促销动作)
最后实现"50% 运费折扣"这个促销动作。文档建议直接参考现有的 FreeShipping 动作——它的perform遍历 shipments 建调整项、compute_amount返回shipment.cost * -1、remove_from清理自己创建的调整项,结构完全可以照搬:
# frozen_string_literal: true module AmazingStore module Promotion module Actions class HalfShipping < ::Spree::PromotionAction # The `perform` method is called when an action is applied to an order or line # item. The payload contains a lot of useful context: # 参见 [promotion.rb 中的 activate 方法](https://link.gitcode.com/i/eb073f4ad5bc77d914d694ccc00b6a06), # payload 包含 order、promotion、line_item、user、path、promotion_code 等键。 def perform(payload = {}) order = payload[:order] promotion_code = payload[:promotion_code] results = order.shipments.map do |shipment| # 如果该 shipment 已经被本促销动作优惠过,则跳过。 next false if shipment.adjustments.where(source: self).exists? # 否则创建一条调整项,为 shipment 施加 50% 折扣。 shipment.adjustments.create!( order: shipment.order, amount: compute_amount(shipment), source: self, promotion_code: promotion_code, label: promotion.name, ) # 返回 true 标记该 shipment 已被优惠。 true end # `perform` 需要在该促销动作实际应用了任何调整项时返回 true,否则返回 false。 results.any? { |result| result == true } end def compute_amount(shipment) shipment.cost * -0.5 end # `remove_from` 应撤销 `perform` 所做的一切。当订单不再满足某促销 # 的资格、促销需要被移除时,该方法会被调用。 def remove_from(order) order.shipments.each do |shipment| shipment.adjustments.each do |adjustment| if adjustment.source == self # 这里只需删除本促销动作在订单 shipments 上创建的调整项。 shipment.adjustments.destroy!(adjustment) end end end end end end end end这里体现了 PromotionAction 基类 定义的完整契约:
perform必须实现(基类直接raise),返回值语义是"本次是否真正施加了调整项"——promotion.rb 的 activate 正是通过results.include?(true)判断action_taken,只有返回true才会建立OrderPromotion关联;remove_from必须实现,用于促销失效时回滚副作用(Spree::Promotion#remove_from会依次调用所有动作的remove_from,再销毁 join 记录);- 与
FreeShipping一样,perform中需要幂等检查(shipment.adjustments.where(source: self).exists?),避免重复施加折扣。
为动作提供(空)表单。和规则类似,动作也需要一个 partial——即使本例不需要任何可配置参数,Solidus 仍会去查找它。根据 PromotionAction#to_partial_path,路径应为spree/admin/promotions/actions/#{model_name.element}:
<!-- Intentionally empty -->[!TIP] 如果你的动作需要可配置参数,可以参照 CreateQuantityAdjustments 动作 及其 后台视图 的做法:动作通过
available_calculators(来自Spree::Config.promotions.calculators[self.class])暴露可选的 calculator 集合,视图则渲染promotion_calculators_with_custom_fields局部模板让管理员挑选计算器并配置其参数。这类动作通常继承Spree::CalculatedAdjustments(例如 CreateAdjustment 与 CreateItemAdjustments 都 include 了它),并在before_validation中兜底创建默认 calculator。
注册动作。同样在 initializer 中追加注册:
# ... Rails.application.config.spree.promotions.actions << 'AmazingStore::Promotion::Actions::HalfShipping'注册的消费端在 PromotionActionsController(promotion_action_types = Spree::Config.promotions.actions)。重启服务器后,后台动作列表中就会出现这个新动作。
补充友好描述:
en: # ... activerecord: attributes: amazing_store/promotion/actions/half_shipping: description: Applies 50% discount in shipping四、端到端验证:从后台配置到前台生效
按文档给出的验收路径走一遍:
- 进入后台Promotions板块,点击New Promotion;
- 本例的促销不需要优惠码,勾选Apply to all orders(对应模型上的
apply_automatically字段,apply_automatically: true的促销会被 Cart 处理器 等自动纳入激活候选;注意apply_automatically与path互斥——见 promotion.rb 中的 apply_automatically_disallowed_with_paths 校验); - 给促销添加Payment规则与Half shipping动作,并选择配置好的支付方式;
- 回到前台,选择该支付方式后即可看到运费被削减 50%。
整个链路的工作方式:顾客选完支付方式 → 订单状态从:payment迁出 → 我们 prepend 的回调调用AmazingStore::PromotionHandler::Payment→ 它找到并激活带Payment规则的促销 →HalfShipping#perform为各 shipment 创建-50%的调整项 → 订单重算后promo_total体现折扣,最终反映在运费金额上。
五、进阶扩展点与运维要点
5.1 配置化扩展点一览
除了向rules/actions类集合追加自定义类,Configuration 还暴露了一批可整体替换的类名配置,供深度定制:
| 配置项 | 默认值 | 用途 |
|---|---|---|
promotion_chooser_class | Spree::PromotionChooser | 决定同一组促销调整项中"谁是最佳促销"的选择器 |
order_adjuster_class | Spree::Promotion::OrderAdjustmentsRecalculator | 自定义促销调整项的重算行为 |
promotion_finder_class | Spree::PromotionFinder | 通过优惠码或 ID 查找促销的查找器 |
shipping_promotion_handler_class | Spree::PromotionHandler::Shipping | 配送环节的促销处理器 |
coupon_code_handler_class | Spree::PromotionHandler::Coupon | 优惠码处理逻辑 |
advertiser_class | Spree::PromotionAdvertiser | 促销"推广"(advertise)行为 |
promotion_code_batch_mailer_class | Spree::PromotionCodeBatchMailer | 优惠码批次异步生成结果的通知邮件 |
此外还有promotions_per_page(后台每页展示的促销数量,默认 15)与promotion_api_attributes(API 返回促销时暴露的属性白名单)两个普通 preference。
5.2 动作与计算器的组合矩阵
calculators是一个嵌套类集合,定义了"哪些计算器可用于哪些动作",默认配置(见 configuration.rb)如下:
- CreateAdjustment(整单折扣):
FlatPercentItemTotal、FlatRate、FlexiRate、TieredPercent、TieredFlatRate; - CreateItemAdjustments(行项目折扣):
DistributedAmount、FlatRate、FlexiRate、PercentOnLineItem、TieredPercent; - CreateQuantityAdjustments(数量折扣):
PercentOnLineItem、FlatRate。
对应计算器实现位于 legacy_promotions/app/models/spree/calculator,例如tiered_flat_rate.rb、tiered_percent.rb、distributed_amount.rb等。
5.3 运维任务
仓库附带一个实用的维护任务:solidus_legacy_promotions:delete_ineligible_adjustments(定义于 delete_ineligible_adjustments.rake),用于直接删除数据库中所有eligible: false的调整项。由于促销重算会不断产生失效的调整记录,定期执行该任务可以控制数据膨胀。
5.4 需要注意的几个约束
- 规则/动作的唯一性:
PromotionRule基类的unique_per_promotion校验保证同一个促销内不会重复出现同一类型的规则; - 订单状态的激活禁区:promotion.rb 定义了
UNACTIVATABLE_ORDER_STATES = ["complete", "awaiting_return", "returned"],这些状态的订单不再接受促销激活; apply_automatically与path互斥:同时设置会触发apply_automatically_disallowed_with_paths校验(见 promotion.rb);- 新促销系统并存:若项目已启用
solidus_promotions新实现,本 Gem 的配置注入会被跳过(engine.rb),两者由Spree::Config.promotions区分,迁移时需注意不要同时配置两套。
结语
Solidus Legacy Promotions 的价值在于"用四个清晰的抽象覆盖所有促销形态":处理器决定何时激活、规则决定是否合格、动作决定折扣内容、调整项负责持久化与动态重算。通过本文的实战,你已经掌握了自定义 handler / rule / action 并注册到后台的完整套路——这套模式同样适用于整单折扣、行项目折扣、运费折扣乃至任意你想象得到的促销场景。在正式项目中,建议持续关注 Solidus 官方对 promotions/ 新实现的推进,为后续从 Legacy Promotions 平滑迁移做好准备。
- 电商
- 后端
- 前端
【免费下载链接】solidus
🛒 Solidus, the open-source eCommerce framework for industry trailblazers.
相关推荐
Solidus Promotions 促销系统深度指南:架构、配置与迁移实战
Solidus Promotions 促销系统深度指南:架构、配置与迁移实战 Solidus Promotions( solidus_promotions )是
电商后端前端企业级RE引擎Mod框架架构解析:高性能模块化开发平台的核心设计
企业级RE引擎Mod框架架构解析:高性能模块化开发平台的核心设计 REFramework作为专为RE引擎游戏打造的企业级Mod框架和脚本平台,为《生化危机》、《
游戏开发VReShop促销系统:优惠券、折扣码与促销活动
eShop促销系统:优惠券、折扣码与促销活动 引言:电商促销的挑战与机遇 在当今竞争激烈的电商环境中,一个强大的促销系统是企业成功的关键因素。eShop作为基于
后端电商前端微服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考