news 2026/9/14 18:59:31

Spree 6.0 配送方式规则(Delivery Method Rules):把运费资格判定从计价计算器中剥离出来

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spree 6.0 配送方式规则(Delivery Method Rules):把运费资格判定从计价计算器中剥离出来

Spree 6.0 配送方式规则(Delivery Method Rules):把运费资格判定从计价计算器中剥离出来

【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree

本文基于 Spree 6.0 的设计规划文档 6.0-delivery-method-rules.md,完整讲解 Spree 6.0 如何将配送方式的“资格限制”(最低/最高商品总额、最低/最高重量、排除商品等)从运费计价计算器中抽离,落成独立的Spree::DeliveryMethodRuleSTI 记录,并在Stock::Estimator的单一过滤缝中统一执行。读完本文,你将理解 Spree 6.0 配送资格模型的设计动机、三类内置规则的实现细节、Admin API v3 的管理接口、FlatRate 旧配置的数据迁移路径,以及如何以插件形式扩展新的规则类型。

一、问题背景:资格判定为什么会“长”在计价器里

在 6.0 之前,Spree 的配送方式资格限制(eligibility bounds)与计价逻辑(pricing)存在双重纠缠,规划文档将其归纳为四个具体问题:

  1. 限制属性只存在于 FlatRate 计算器上minimum_item_total/maximum_item_total/minimum_weight/maximum_weightCalculator::Shipping::FlatRate的 preference,而 FlexiRate、PerItem、PriceSack、FlatPercentItemTotal 等其他运费计算器根本不提供这些限制——也就是说,一个配送方式能不能用,取决于你当初选了哪种计价公式。在当前仓库的 flat_rate.rb 中可以看到,这四个 preference 已被标注为deprecated,废弃信息明确写着:“改用配送方式上的Spree::DeliveryMethodRules规则;将在 Spree 6.1 移除”。
  2. “不合格”被编码成“无价格”。旧实现靠compute_package返回nil来表示“此包裹不符合资格”(见 flat_rate.rb 的 compute_package:四个return nil if ...守卫)。而 Estimator 真正会去询问的ShippingCalculator#available?(package)钩子,FlatRate 从未使用。
  3. 配送服务商(rate provider)绕过了限制。按照 6.0-delivery-rate-provider.md 的设计,由 EasyPost/Shippo 等服务商报价的配送方式会绕过计算器直接向服务商询价——承载资格限制的计算器对象被整体跳过,限制于是“静默失效”。
  4. 管理台(Dashboard)呈现错位。这些限制属性渲染在“运费计算器”的配置表单里,商户看到的是一堆“计价配置”,读不出“资格限制”的语义。

规划文档还引用了平台调研结论:Shopify 在 zone rate 上直接放重量/价格条件,Saleor 在方式上放列(外加按渠道的价格边界),Medusa v2 使用类型化的ShippingOptionRule记录,Vendure 为 ShippingMethod 提供与计算器平行的ShippingEligibilityChecker策略——没有任何主流平台把资格判定埋进计价对象内部。这正是本次重构的方向。

二、核心设计决策:复刻“房屋规则模式”(House Rule Pattern)

Spree::DeliveryMethodRule是 Spree 内部“规则模式”的第五个实例,前四个是PromotionRulePriceRuleOrderRoutingRulePaymentMethodRule。规划文档在 Key Decisions 一节列出了不可随意偏离的决策清单,以下是其中最关键的部分:

  • 镜像Spree::PaymentMethodRule,不另造框架。STI 基类落在spree_delivery_method_rules表上,子类放在app/models/spree/delivery_method_rules/目录下,配置由 preference schema 驱动,type字段经过注册表(registry)校验。凡是与 6.0-payment-method-rules.md 重叠的部分(基类形状、AND 语义、空 preference 失败开放、注册表、仅 Dashboard 管理),以支付规则规划文档的措辞为权威——两套规则保持对称。
  • 用规则而不是方式上的列。Saleor 式的列方案能覆盖第一阶段(Phase 1),但会堵死支付侧已承诺的渠道/市场/客户组(channel/market/customer group)扩展轴。一种模式,服务两套方式。
  • 无规则 = 处处可用(No rules = eligible everywhere)。在数据迁移任务运行之前,升级 6.0 不产生任何行为变化。
  • AND 语义;每个配送方式每种规则类型只允许一个实例,唯一性约束在[:type, :delivery_method_id]上。
  • 唯一的执行缝是 Estimator 的方法过滤链Stock::Estimator获得delivery_method.eligible_for_package?(package),与calculator.available?处于同一过滤链中。三个入口(Fulfillment#refresh_ratesOrderRouting::Strategy::RulesCarts::EstimateShippingRates)因此免费获得统一执行。与支付规则不同,这里没有“管理端旁路”概念:Estimator 是唯一的费率来源,而重量/总额边界是物流约束,不是商店前台的门禁。
  • 规则只评估包裹及其所有者package.weightpackage.item_total,以及后续规则用到的package.owner.channel_id),绝不读取Spree::Current这类全局当前值。
  • WeightRule 按商店隐含的重量单位比较原始数字,与旧 FlatRate 边界行为一致;Dashboard 标签会展示商店的unit_system。带单位感知的重量值是另一个未来项目。
  • 规则只作用于配送方式作用域(method-scoped),永远不带 zone 上下文。想按地区使用不同边界?按“区域化方式”(regional methods)决策拆成两个配送方式。Estimator 的 zone 过滤独立运行。
  • 管理仅限 Dashboard:Admin API v3 嵌套 CRUD + 配送方式编辑页上独立的“Conditions”卡片,与计算器的计价 preference 分开呈现。

三、基类与规则模型:源码级实现

基类Spree::DeliveryMethodRule

规划文档给出的设计代码与仓库中的实际实现 delivery_method_rule.rb 高度一致。核心结构如下(取自当前源码):

# spree/core/app/models/spree/delivery_method_rule.rb module Spree class DeliveryMethodRule < Spree.base_class include Spree::PreferenceSchema has_prefix_id :dmrule # API 前置 ID 前缀,如 dmrule_1 belongs_to :delivery_method, class_name: 'Spree::DeliveryMethod', inverse_of: :delivery_method_rules, touch: true delegate :store, to: :delivery_method attribute :active, :boolean, default: true validates :type, presence: true # 每种规则类型每个方式仅一个实例——AND 语义下重复项要么无效、要么自相矛盾 validates :type, uniqueness: { scope: [:delivery_method_id, *spree_base_uniqueness_scope] } validate :type_must_be_registered scope :active, -> { where(active: true) } registers_subclasses_via { Spree.delivery_method_rules } # @param package [Spree::Stock::Package] def eligible?(_package) raise NotImplementedError, "..." end end end

值得注意的实现细节:

  • has_prefix_id :dmrule:规则实体使用带前缀的 API ID(如dmrule_xxx),与 Spree 其他实体一致;
  • touch: true:规则变化会刷新所属配送方式的updated_at
  • registers_subclasses_via { Spree.delivery_method_rules }:子类发现(STI 注册表)指向引擎配置数组,插件可以扩展;
  • type_must_be_registered校验器(第 48-53 行)会在type不在Spree.delivery_method_rules注册表中时拒绝保存,错误消息为:invalid_delivery_method_rule
  • 类方法human_name/human_descriptiondelivery_method_rule_types.<api_type>.name/description翻译键取本地化文案,供管理端选择器展示。

ItemTotalRule 与 WeightRule:两个边界规则

这两个规则是第一阶段的核心(对应今天已存在的两个约束维度),实现完全按规划文档中的形状落地:

# spree/core/app/models/spree/delivery_method_rules/item_total_rule.rb class Spree::DeliveryMethodRules::ItemTotalRule < Spree::DeliveryMethodRule preference :minimum_amount, :decimal, default: nil, nullable: true preference :maximum_amount, :decimal, default: nil, nullable: true def eligible?(package) total = package.item_total return false if preferred_minimum_amount.present? && total < preferred_minimum_amount return false if preferred_maximum_amount.present? && total > preferred_maximum_amount true end end

weight_rule.rb 形状完全相同,只是把比较对象换成package.weight,preference 为minimum_weight/maximum_weight(同样是:decimalnullable: true、默认nil)。

语义要点(规划文档“Boundary semantics”一节):

  • 边界语义为“含边界的 min / 含边界的 max”total < min判不合格,total > max判不合格,等于边界值判合格;
  • 旧 FlatRate 的守卫在最小值一侧是开区间(源码为preferred_minimum_item_total >= package.item_total时返回 nil,即恰好等于最小值也不合格);数据迁移任务保留商户已存的数字,接受这个“差一分钱”的边界变化——规划文档明确将其定性为bug 修复而非回归
  • 空 preference 失败开放(fail-open):只配置了minimum_amount而未配置maximum_amount的规则,只对最小值做判定。这与PromotionRule/PriceRule的惯例一致(见 item_total_rule.rb 的注释:“半配置的规则按每规则失败开放”);
  • 金额比较使用包裹所有者的货币,即“currency-aware via the owner's currency”,规则本身不携带货币设置。

ExcludedProductsRule:取代产品侧 JSON 列

2026-08-06 加入的决策:废弃spree_products.excluded_delivery_method_ids列(该列由履约重构 Phase 1b 加入,从未通过任何 API 或 UI 暴露,仅控制台可写),改为“脆弱商品不能走快递”式的方式侧排除规则。规划文档给出的选型理由非常具体:

  • JSON 里的 ID 列表在三种受支持的数据库上无法被索引或可移植地查询;
  • 配送方式删除后会留下陈旧 ID;
  • API 边界上被迫引入 raw-ID / 前置-ID 的映射垫片;
  • 方法侧排除正是 Saleor 的模型(ShippingMethodType.excludedProducts+shippingPriceExcludeProducts),并且把资格判定保持在 Estimator 这一道缝里,而不是回到Stock::Package#eligible_delivery_methods内被废弃的“第二道缝”。

当前源码 excluded_products_rule.rb 的关键实现:

class Spree::DeliveryMethodRules::ExcludedProductsRule < Spree::DeliveryMethodRule has_many :delivery_method_rule_products, class_name: 'Spree::DeliveryMethodRuleProduct', foreign_key: :delivery_method_rule_id, dependent: :destroy, inverse_of: :delivery_method_rule has_many :products, class_name: 'Spree::Product', through: :delivery_method_rule_products self.additional_permitted_attributes = [product_ids: []] def eligible?(package) product_ids = package.contents.map { |item| item.variant.product_id }.uniq return true if product_ids.empty? # target 覆盖“已分配但尚未保存”的关联行(管理控制器先赋产品再存规则) return false if delivery_method_rule_products.target.any? { |link| link.new_record? && product_ids.include?(link.product_id) } return true if new_record? !delivery_method_rule_products.exists?(product_id: product_ids) end end

实现上有三个值得一提的细节:

  1. 判问方向反过来了:不是把整个排除清单加载进内存再比对,而是用包裹内商品 ID 去问中间表“有没有交集”——中间表 spree_delivery_method_rule_products 的复合索引([delivery_method_rule_id, product_id],唯一)可以无行传输地回答;
  2. 未保存记录的内存态处理delivery_method_rule_products.target读取的是“已在内存中的目标”,不会触发数据库加载,因此能覆盖“管理控制器先给规则赋产品、规则还没保存”这个窗口期;
  3. 软删除商品的读取面product_prefixed_ids通过products关联 pluck 再排序编码为前置 ID,软删除的商品自动从返回列表中消失——客户端重新提交时不会被告知一个它从未选择过的商品“不可达”(第 16-25 行)。

中间模型 delivery_method_rule_product.rb 镜像Spree::ProductPromotionRule:双向belongs_to,并在[delivery_method_rule_id]作用域内对product_id唯一。

语义边界:包裹内含有任意一个被排除商品,即不合格没有关联商品的规则放行(fail-open,与空 preference 惯例一致)。由于该列从未通过受支持的 API 或 Dashboard 可写,不存在数据迁移——直接迁移到规则即可;曾在 6.0 预发布期用 Rails 控制台手工写入该列的人需要把值重建为各配送方式上的excluded_products_rule

四、执行点:Estimator 方法过滤链

整个运行时改动只有一行——把eligible_for_package?放进 Estimator 现有的过滤链。当前仓库 estimator.rb 的 filter_delivery_methods:

def filter_delivery_methods(package, audience) methods = package.eligible_delivery_methods methods = methods.merge(order.store.delivery_methods) if order.store # ... 市场多卖家场景下 package 由货位的卖家报价 ... methods.select do |delivery_method| offered_to_seller?(delivery_method, package_seller_id) && delivery_method.available_to?(audience) && delivery_method.include?(order.ship_address) && # zone 过滤,独立运行 delivery_method.serves_location?(package.stock_location) && delivery_method.eligible_for_package?(package) && # ← 规则执行缝 calculator_offers?(delivery_method, package) end end

DeliveryMethod侧的实现就是规划文档中的 AND 聚合(无规则 = 全部通过):

# AND over active rules; no rules = eligible. def eligible_for_package?(package) delivery_method_rules.select(&:active).all? { |rule| rule.eligible?(package) } end

这样做的直接收益:无论配送方式由计算器定价还是由服务商(EasyPost/Shippo 等)定价,资格判定都发生在同一处——服务商报价路径不再绕过限制,这正是规划文档要堵住的“provider bypass”。同时 Estimator 中存在一条向后兼容的delivery_methods/shipping_methods派发逻辑(第 163-175 行):宿主应用若覆写了旧缝shipping_methods,会被自动识别并沿用其定义,保证 6.1 之前旧代码不会静默漏掉本应过滤的方式。

五、注册表:类型校验与插件扩展

规则类型通过引擎配置数组注册。当前 engine.rb 中的注册内容是:

# Delivery-method eligibility rule kinds (docs/plans/6.0-delivery-method-rules.md). Rails.application.config.spree.delivery_method_rules.concat [ Spree::DeliveryMethodRules::ItemTotalRule, Spree::DeliveryMethodRules::WeightRule, Spree::DeliveryMethodRules::ExcludedProductsRule, Spree::DeliveryMethodRules::ChannelRule, Spree::DeliveryMethodRules::VolumeRule, Spree::DeliveryMethodRules::CompanyRule ]

配置项由 core.rb 的Spree.delivery_method_rules/Spree.delivery_method_rules=读写,形状与Spree.payment_method_rules/Spree.order_routing.rules相同——插件只需向这个数组 concat 自己的规则类,基类的type_must_be_registeredregisters_subclasses_via(从而find_by_api_typeSpree.delivery_method_rules解析)与 API 的 types 发现端点都会自动带上它,无需改动任何控制器代码。

从当前源码结构看,注册表已不止规划文档第一阶段所列的三类:ChannelRuleVolumeRuleCompanyRule(对应app/models/spree/delivery_method_rules/下的 channel_rule.rb、volume_rule.rb、company_rule.rb)已经就位。规划文档原本的安排是“Channel / Market / CustomerGroup 跟随支付规则的节奏一起落地,使两组规则在 Dashboard 中同时出现”,仓库现状表明这条扩展路线正在兑现。

六、管理接口:Admin API v3 与 Dashboard

端点

  • GET/POST/PATCH/DELETE /api/v3/admin/delivery_methods/:id/rules——嵌套 CRUD,扁平参数(type的 wire 简写 +preferences);
  • GET /api/v3/admin/delivery_method_rules/types——发现端点,返回每个已注册规则类型的typenamedescriptionpreference_schema,以及本次新增的association_fields

发现端点的实现见 delivery_method_rules_controller.rb:

def types authorize! :create, Spree::DeliveryMethodRule data = Spree.delivery_method_rules.map do |klass| { type: klass.api_type, name: klass.human_name, description: klass.human_description, preference_schema: klass.serialized_preference_schema, # 规则在 preferences 之外接受的关联型配置(如 product_ids), # 让管理端 UI 无需硬编码规则类型即可渲染正确的编辑器 association_fields: association_fields_for(klass) } end render json: { data: data } end

association_fields直接派生自各规则类的additional_permitted_attributesExcludedProductsRule声明了product_ids: [],对应 第 14 行)。这解决了规划文档指出的一个前后端不对称问题:没有它,后端是注册表驱动的,而客户端不是——插件带来的关联型规则会被 API 接受,却在 Dashboard 里渲染成空白的 preferences 表单,提交时 ID 被丢弃。有了该字段,Dashboard 的 Conditions 卡片对任何声明了product_ids的规则都渲染商品选择器,而不是PreferencesForm。规划文档同时说明:完整的方案是 Dashboard 侧的“slot registry”(推广编辑器模式,约 150 行新结构),被刻意推迟——触发条件是“第二个需要非 preference 配置类型的规则”或“第一个插件自带的规则”出现。

rules=扁平载荷写入器

2026-08-06 的决策推翻了最初“每条规则一个立即写入的 Save 按钮”的做法(一个编辑页上出现两个 Save 按钮,且与所有兄弟编辑器不一致),改为:规则随方式一起保存DeliveryMethod#rules=是一个基于Spree::TypedAssociations的扁平载荷写入器——与Promotion#rules=PriceList#rules=同一机制。一次 POST/PATCH 携带rules: [...]即完成对账(reconcile):

  • 已有行按id更新;
  • 新行经find_by_api_type构建;
  • 载荷中省略的行被销毁。

嵌套的/delivery_methods/:id/rules独立端点保留给程序化使用。

product_ids的解析策略

关联型规则的关联写入以前置 ID 参数(product_ids)承载,控制器必须同时经过商店作用域调用方能力accessible_by(current_ability, :show))解析——与DeliveryMethodsController解析税类别/区域/自提点的做法一致(模型写入器本身不做这两层校验)。解析失败的 ID 被从选择集中丢弃,而不是让保存 404:一个不可达的商品 ID 无论如何都成不了排除项,而失败会困住那个“产品被删除时表单还开着”的商户(与价格列表过滤成员的策略一致)。序列化器以同名product_ids回显,保证读写对称。

七、迁移路径与弃用桥

规划文档的 Migration Path 共七步,当前仓库能看到前六步的落地证据:

  1. 表 + 基类 + 规则 + 注册表(纯增量)——20260729130001_create_spree_delivery_method_rules.rb;
  2. Estimator 过滤行 +eligible_for_package?——见上文第四节;
  3. FlatRate 四个边界 preference 的弃用警告——flat_rate.rb 中四个 preference 已带deprecated:元数据,其compute_package的 nil 守卫作为弃用桥保留至 6.1;
  4. Admin API + Dashboard Conditions 卡片 + SDK + OpenAPI——控制器、序列化器(admin/delivery_method_rule_serializer.rb)均已就位;
  5. 数据任务spree:migrate_calculator_bounds_to_delivery_method_rules——完整实现见 delivery_method_rules_migration.rake,行为要点:
    • 遍历所有挂在Spree::DeliveryMethod/ 旧Spree::ShippingMethod上的 FlatRate 计算器;
    • 若设置了minimum/maximum_item_total且该方式尚无ItemTotalRule,创建规则并携带数值;重量同理;
    • 已存在同类型规则则跳过(幂等);
    • update_columns直接清掉计算器上的四个旧边界,避免旧 nil 守卫与新规则双重执行;
    • 该任务挂在 5.6→6.0 升级清单 manifest.yml 中migrate_shipping_to_delivery之后执行;
  6. ExcludedProductsRule——20260806000001_create_spree_delivery_method_rule_products.rb 建中间表,产品侧列删除,规则注册、product_ids参数与 Dashboard 商品选择器;
  7. 6.1:删除四个 preference 与 FlatRate 的 nil 守卫(尚未执行)。

测试侧可参考 delivery_method_rule_spec.rb(模型层:注册校验、唯一性、各规则 eligible? 语义)与 delivery_method_rules_controller_spec.rb(API 层:嵌套 CRUD、types 发现、product_ids权限过滤)。

八、对现有工作的约束与扩展指南

规划文档“Constraints on Current Work”一节给出三条硬约束,对贡献者和插件作者仍然有效:

  • 不要再给任何计算器添加资格类 preference——新约束一律等规则;
  • 不要基于 FlatRate 边界构建商店前台功能——它们是弃用桥,6.1 即拆除;
  • Estimator 过滤链是资格判定的唯一挂载点——控制器/服务层里不得出现逐调用点的资格检查。

扩展一个新规则类型的最小路径(以插件为例):在app/models/spree/delivery_method_rules/下新建 STI 子类,声明preference(或如ExcludedProductsRule那样声明additional_permitted_attributes),实现eligible?(package),然后向Rails.application.config.spree.delivery_method_rules注册。此后类型校验、API 发现端点、find_by_api_type解析、Dashboard 编辑器选择全部自动生效——这正是“注册表驱动”这套模式的复利所在。

小结

Spree 6.0 的 Delivery Method Rules 用一个五度复用内部规则模式的机制,回答了“配送方式在什么情况下可用”这个此前被埋在计价公式里的问题:STI 基类 + preference 配置 + 注册表校验,AND 语义且无规则即全通,执行点唯一收敛在Stock::Estimator的过滤链上,使计算器定价与服务商定价的方式服从同一套资格;ItemTotalRule/WeightRule承接存量约束并通过幂等 rake 任务完成 5.6→6.0 数据搬迁,ExcludedProductsRule则以具体外键中间表取代了不可查询、留陈旧数据的 JSON ID 列;管理面由 Admin API v3 嵌套 CRUD 与“types 发现 +association_fields”驱动 Dashboard 的 Conditions 卡片,插件扩展规则类型无需触碰 API 层。规划文档标注 Phase 1 已实现、ExcludedProductsRule(决策 2026-08-06,PR #14399)在评审中,而当前仓库源码显示其模型、迁移、控制器与注册表均已落地,后续 6.1 将完成 FlatRate 旧 preference 的拆除。

【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 18:56:15

公共场所暴力与正常行为监控视频数据集

摘要&#xff1a;该数据集面向开放环境下的暴力行为识别&#xff0c;包含正常行为与暴力行为两类视频。数据集概述该数据集面向开放环境下的暴力行为识别&#xff0c;包含正常行为与暴力行为两类视频。正常视频主要来源于固定监控摄像机&#xff0c;暴力视频来源于公开视频及经…

作者头像 李华
网站建设 2026/9/14 18:54:42

搞定 LogicFlow 官网加载失败:3 步实战修复

搞定 LogicFlow 官网加载失败&#xff1a;3 步实战修复 【免费下载链接】LogicFlow A flow chart editing framework focus on business customization. 专注于业务自定义的流程图编辑框架&#xff0c;支持实现脑图、ER图、UML、工作流等各种图编辑场景。 项目地址: https://…

作者头像 李华