Spree 6.0 Store-Scoped Configuration:把商业行为配置从全局 Spree::Config 迁往 Store 偏好的完整实战指南
2026/9/14 15:35:02 网站建设 项目流程

Spree 6.0 Store-Scoped Configuration:把商业行为配置从全局 Spree::Config 迁往 Store 偏好的完整实战指南

【免费下载链接】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 将八项「商业行为」全局配置(扣款时机、库存跟踪、价格历史等)从Spree::Config迁移到Spree::Store偏好之上,并废弃了一批无人读取的死设置。本文基于 docs/plans/6.0-store-scoped-configuration.md 的设计决策,结合仓库源码(StorePreferencesconcern、CaptureMethodconcern、store_settings.rake回填任务等)逐层拆解迁移动机、核心取舍与升级路径,帮助你在多店铺(multi-store)场景下正确使用新的 Store 级偏好,并安全完成 5.6 → 6.0 的升级迁移。

背景:为什么 Spree 6.0 要重构配置体系

Spree 的配置历史上集中在Spree::Config(其底层实现在 spree/core/lib/spree/core/configuration.rb)中。2026-08-04 对全部 68 个偏好做了一次带对抗性验证的完整使用审计,结论是:38 个真实活跃、6 个只有 6.0 已替换掉的旧子系统还在读、7 个全仓库零读取

更关键的发现是,这些活跃设置天然分成两类:

  • 应用配置(application configuration)——安全、限额、后台任务、URL 等,描述的是「这套安装」的属性。例如密码长度,它保护的是应用而不是某个店铺。
  • 商业行为配置(commerce behavior)——扣款时机、库存跟踪、目录可见性等,描述的是「某个店铺怎么卖货」的属性。

在 6.0 之前,Product、Promotion、PaymentMethod、StockLocation 已经陆续变成单店(single-store)资源(见 6.0-channels-catalogs-b2b.md),此时再让「按店而异的商业行为」由全局标志控制,就成了架构上的坏味道。此前companycompany_field_enabledallow_guest_checkoutguest_checkout已经开了头,本次计划把剩下的八项商业行为全局配置一并迁到Spree::Store偏好,并淘汰掉那些死设置。

核心问题:两个真相来源必然产生漂移

整个计划由一个具体 bug 触发:default_stock_reservation_ttl_minutes这个全局配置只在 Store 对应偏好为空时才会被读取。但Store#stock_reservation_ttl_minutes声明了default: 10且带greater_than: 0校验,所以它永远不会为空——全局配置实际上对所有带 store 的订单都静默失效了。由于两处默认值恰好都是 10,没有任何人察觉。

这就是「同一行为存在两个真相来源」的后果:一个悄悄生效,另一个成为文档里的谎言。同样的形状也在别处出现——auto_capture既存在于全局,又以列的形式存在于每个支付方式上,而 Dashboard 只暴露列。

分类测试:一个设置该留在全局还是迁到 Store

判断标准非常简单,一句话:

同一套安装上的第二个店铺,是否会合理地想要不同的值?

  • → 应该是 Store 偏好。例如欧盟店铺需要track_price_history(满足欧盟 Omnibus 指令),其非欧盟姊妹店铺不需要。
  • 不会→ 留在Spree::Config。例如密码长度保护的是应用本身,而不是某个店铺的销售方式。

八个迁移项一览

下表来自计划文档,名称与默认值在 Store 上保持不变(唯二例外是合并后的两个 capture 布尔,见下文):

全局配置(今日)Store 偏好默认值
auto_capture+auto_capture_on_dispatchpreferred_capture_method'checkout'
track_inventory_levelspreferred_track_inventory_levelstrue
stock_reservations_enabledpreferred_stock_reservations_enabledtrue
track_price_historypreferred_track_price_historytrue
show_products_without_pricepreferred_show_products_without_pricefalse
address_requires_phonepreferred_address_requires_phonefalse
disable_sku_validationpreferred_disable_sku_validationfalse

这些偏好都已在 spree/core/app/models/spree/store.rb 中声明,例如preference :track_inventory_levels, :boolean, default: truestore.rb第 118 行)、preference :capture_method, :string, default: Spree::CaptureMethod::DEFAULT_CAPTURE_METHOD(第 102 行)、preference :disable_sku_validation, :boolean, default: false(第 123 行)等,并有validates :preferred_capture_method, inclusion: { in: Spree::CaptureMethod::CAPTURE_METHODS }(第 336 行)这类取值校验。

关键决策与设计取舍

1.capture_method:两个布尔合并为一个三值字符串

最初计划按「一全局一偏好」逐个搬运,但对 capture 这一对做了例外(2026-08-13 决定)。原因在于auto_capture+auto_capture_on_dispatch两个布尔用四种组合编码三种真实行为,其中一种组合自相矛盾:

  • 两者都开:结账时钱已经扣走,发货时的 capture 是死操作(no-op);
  • 更糟的是,第三种真实行为——结账时仅授权、由员工稍后手动收款——只能表达为「两个都关」,没有任何商家能自己发现这个用法。

于是合并为单一字符串偏好,词汇表是checkout | on_dispatch | manual,定义在 spree/core/app/models/concerns/spree/capture_method.rb(CAPTURE_METHODS = %w[checkout on_dispatch manual],默认'checkout'),与 Shopify 的三选项支付扣款设置一致。这样自相矛盾的组合变得不可表达,manual 模式则变得显式可见。

  • checkout:下单即扣款;
  • on_dispatch:结账时仅授权,发货时扣款;
  • manual:结账时仅授权,留给员工手动收款。

对应的语义提问方法也在该 concern 中:capture_at_checkout?capture_on_dispatch?capture_manually?

2. PaymentMethod 上是列,不是偏好

auto_capture原本是支付方式表里的布尔列(与activeposition并列),替换它的capture_method也保持为列,而不是偏好。理由(写在迁移文件 spree/core/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb 中):支付方式上的 preferences 哈希存放的是各家网关的凭证,把核心设置放进去,会把它渲染成 provider 配置表单里的凭证字段,还不得不在PreferenceSchema里加排除清单——需要逃生舱本身就说明存储位置错了。列还可以被 SQL 查询(逐支付的 dispatch 检查正需要),null则天然承载「继承 store 选择」的语义(与Spree::Channel::Gating的 nullable 继承如出一辙)。

在 Store 上它仍是偏好,因为 Store 的所有设置都以偏好形式存储。

解析链(spree/core/app/models/spree/payment_method.rb 的resolved_capture_method,第 239-244 行):

def resolved_capture_method return capture_method if capture_method.present? # 1. 方法列优先 return 'checkout' if auto_capture # 2. 旧布尔列兼容 store_preference(:capture_method).presence || Spree::CaptureMethod::DEFAULT_CAPTURE_METHOD end # 3. store 偏好 → 声明默认值

auto_capture?保留为不告警的弃用提问方法(它跑在每一笔支付上,告警会刷爆日志)。

3. dispatch 扣款按支付方式逐笔判定,而非店铺级一刀切

spree/core/app/models/spree/fulfillment.rb 的payments_to_capture_on_dispatch(第 484-488 行)只挑出「支付方式解析为on_dispatch」的待处理付款:

def payments_to_capture_on_dispatch pool = grouped_owner? ? owner.settlement_payments.pending : Array(owner&.pending_payments) Array(pool).select { |payment| payment.payment_method&.capture_on_dispatch? } end

随后process_order_payments(第 490-514 行)按未扣金额从大到小排序、按发货价逐笔 capture。选择manual的支付方式永远不会被发货动作扫走——这正是商家选择 manual 的全部意义。发货就绪判定同理:当某个支付方式故意延迟收款时,已授权未扣款的订单可以放行发货,而不是被一个店铺级布尔拦住。

4. Store 偏好是唯一权威,不做运行时回退

核心代码只读 store 偏好 + 其声明默认值,绝不回退读旧全局。回退链会重新引入让default_stock_reservation_ttl_minutes不可达的那类漂移。company先例已经如此工作。

5. 无 store 的读取:Spree::Current.store+ 声明默认值

有两个读取者没有记录级 store 可问:Address(无 store 关联)和 Product 可用性 scope(类级)。它们都通过Spree::Current.store解析。已接受的权衡是:在请求之外运行的校验或目录查询(控制台、seed、忘记设置 store 的后台任务)会静默使用默认值而非该 store 的值。因此计划明确要求:任何校验地址或查询目录的后台任务都必须设置Spree::Current.store

6. 其余关键决策摘要

  • default_stock_reservation_ttl_minutes弃用而非迁移Store#stock_reservation_ttl_minutes早已持有该值,StockReservation.ttl_for去掉全局读取,对无 store 场景保留硬性的 10 分钟下限。
  • track_price_history6.0 先落在 Store,Market 记为未来精化项(欧盟 Omnibus 是分国立法,Market 已拥有return_window_days等法律类设置,但价格目前未按 Market 划分,留待价格/Market 作用域设计时再议)。
  • 看似行为型但留在全局的:credit_to_new_allocation(账本形态约定,按店差异会让一套安装的记账口径不一致)、non_expiring_credit_types(参考数据,已在 store credit 分类移除时整体废弃)、geocode_addresses(地理编码是基础设施,依赖安装的 provider 凭证与配额,与店铺怎么卖无关)。
  • allow_checkout_on_gateway_error直接丢弃而非迁移:Spree 6 中没有任何代码读取它,且Carts::Complete#process_paymentsOrders::Complete#process_payments已改为检查支付是否覆盖总额,Orders::Complete还会在 gateway 错误时向errors追加消息并失败——开着它订单也完不成,放进 Dashboard 只会是个死开关。
  • address_requires_state直接丢弃而非迁移:它已被废弃,且只是国家自身states_required标志的重复开关。Address#state_validate现在只读国家标志;把该设置设为false而国家要求州名的店铺,其地址将从迁移后开始校验失败——应该去改国家标志。
  • 七个零读取的死设置在 6.0 加了弃用壳(products_per_pagealternative_shipping_phoneshow_variant_full_pricereserve_stock_onstorefront_products_pathstorefront_taxons_pathstorefront_pages_path),6.1 删除。壳只用于让「在 initializer 里设置了它们的」安装在升级中途不因启动崩溃。
  • 新行为标志从 6.0 起一律从 Store(或按区域从 Market/Channel)出生,绝不进入Spree.config

源码级原理:Spree::StorePreferences读取器

迁移后所有重新指向的读取点都经由 spree/core/app/models/concerns/spree/store_preferences.rb 统一读取。include 该 concern 的模型通过定义preference_store说明自己如何到达一个 store(Variant 经由其 product,Price 经由 variant → product,Fulfillment 经由其 owner);preference_store默认实现是「有store关联就返回它,否则 nil」。

def store_preference(name) Spree::StorePreferences.read(preference_store, name) end def preference_store respond_to?(:store) ? store : nil end class << self def read(store, name) return store.get_preference(name) if store Spree::Store.new.preference_default(name) # 无 store → 声明默认值 end def current(name) read(Spree::Current.store, name) # 解析环境 store end end

两种解析方式有细微且重要的差异(计划文档明确强调):

  • Spree::Current.store自身会回退到Spree::Store.default,所以.current(name)返回的是默认 store 的配置值,只有当整套安装没有任何默认 store 时才会落到声明默认值;
  • .read(nil, name)始终返回声明默认值

因此:环境 store 就是正确答案时用.current,某个具体记录的 store 才是正确答案时用.read(record_store, name)。这在地址校验和商品可用性上会产生行为差异。测试用例 spree/core/spec/models/concerns/spree/store_preferences_spec.rb 明确覆盖了「无 store 时回退到声明默认值」和「跟随被覆盖的preference_store」两种行为。

顺带修的两个 bug:Address#show_company_address_field?原来会在无 store 读取时抛异常(Spree::Store.current.prefers_…);商品可用性 scope 原来从Spree::Store.default取回退货币而非环境 store,导致多店铺安装按错误店铺的货币过滤目录。

读取点重新指向一览

计划文档记录了所有读取点迁移后的到达路径(均已落库):

设置读取点Store 到达方式
capture_methodpayment_method.rbresolved_capture_method)、fulfillment.rbprocess_order_payments)、fulfillments/fulfill.rbPaymentMethod 为 store 所有;fulfillment → order/cart → store
track_inventory_levelsvariant.rbproduct.rbvariant → product → store
stock_reservations_enabledstock_reservations/reserve.rbstock_reservations/extend.rbstock/quantifier.rbcart/order → store;stock_item → stock_location → store
track_price_historyprice.rbprice → variant → product → store
show_products_without_priceproduct_scopes.rbSpree::Current.store(类级 scope)
address_requires_phoneaddress.rbaddresses/phone_validator.rbSpree::Current.store(无 store 关联)
disable_sku_validationvariant.rbvariant → product → store

Product.storeoptional: true(见product/channels.rb),所以每个被重指(re-pointed)的读取者都以偏好默认值作为「无 store」时的兜底。

附带修复:迁移之外的顺手清理

  • coupon_codes_total_limit在类加载时被插值进Promotion的数值校验(promotion.rb),在 initializer 里晚于加载设置它不会生效——校验选项应改为 lambda。
  • disable_sku_validation的定义注释(configuration.rb,「when turned off disables」)写反了——true才是禁用校验。
  • storefront_products_path不只是无人读:base_helper.rb和 Google feed presenter 硬编码/products/,覆盖它今天会静默产生损坏的 feed URL,删除它反而让问题显式化。

迁移路径:Phase 1 / 2 / 3

Phase 1(6.0):移动 + 弃用(已上线 2026-08-12)

  1. Spree::Store上新增八个偏好,默认值与全局一致;
  2. 按上表重指读取点;Address与 product scope 读Spree::Current.store
  3. spree/core/lib/spree/core/configuration.rb中把八个全局配置与死设置标记为deprecated:(访问时经Spree::Deprecation告警);
  4. 交付spree:store_settings:backfill_from_config并加入 5.6→6.0 升级清单(与sanitize_rich_text同机制);
  5. StockReservation.ttl_for去掉全局读取。

回填任务(backfill)的实现与坑

任务实现在 spree/core/lib/tasks/store_settings.rake。MOVED_SETTINGS哈希把全局名映射到 store 偏好名与默认值(注意两个改名项:companycompany_field_enableddefault_stock_reservation_ttl_minutesstock_reservation_ttl_minutes,任务按名映射而非假设同名)。执行逻辑:

task backfill_from_config: :environment do changed = MOVED_SETTINGS.reject do |name, config| Spree::Config.send(name) == config[:default] # 仍为默认值的全局不拷贝 end # ...逐 store 拷贝,跳过已定制项,记录元数据标记 end

两个 capture 布尔不是按名拷贝,而是由CAPTURE_METHOD_FROM_CONFIGlambda 一起推导:auto_capture开 →'checkout';否则auto_capture_on_dispatch开 →'on_dispatch';两者都关 →'manual'。若推导结果等于默认'checkout'则不写。

关键坑:store 实例化的瞬间会把每个声明默认值写进自己的 preferences 哈希,因此 key 永远存在,无法用preferences.key?或与preference_default比较来区分「商家选的值」与「种子默认值」——没有「显式设置」标志可查。所以任务在 store 的 metadata 里记录store_settings_backfilled_from_config标记,每个 store 至多访问一次;残余风险是单向且很小的:单次运行时,某个值恰好等于默认值的 store 会采用全局的值,此后标记保护所有后续变更。任务幂等,且逐 store 打印它写入的每个设置。

配套迁移spree:migrate_capture_methodsauto_capture: true的行复制为'checkout'false的行留空——布尔只记录了「不在结账时扣」,区分不了 dispatch 与 manual,所以这些行继续继承 store 的选择。

测试策略:stub 而非写入

规格通过Spree::TestingSupport::Preferences#stub_store_preferences驱动——stub 而不是写库,因为默认 store 是全测试套件共享的,一个例子持久化设置会污染下一个。stub 以 store id 为键(被测代码经关联或 reload 拿到的是同一行的不同实例),其他 store 与未命名偏好照常自行回答。

Phase 2(6.0):暴露

  1. Admin API v3 store serializer 与 permitted params 加入八个偏好,重新生成类型;
  2. Dashboard 设置页在自然分区(payments、inventory、catalog、checkout)浮现新开关;
  3. 文档:将八个设置移入 store-settings 文档(当前仓库对应页面为 docs/developer/customization/configuration.mdx,保持 commerce / application 两组结构)。

Phase 3(6.1):删除

  1. 删除八个已弃用全局、死设置及文中列出的遗留壳。

对当前开发工作的硬约束

计划文档明确了迁移期间的编码纪律:

  • 不要新增对八个迁移项的Spree::Config读取——它们已弃用、core 不再读。一律经Spree::StorePreferences(include concern 并定义preference_store,或调用.read/.current);
  • 新行为标志从出生就放 Store——绝不把商业行为偏好加进Spree.config
  • 不要基于auto_capture/auto_capture_on_dispatch构建 UI 或代码——Store 与 PaymentMethod 上均已弃用。读resolved_capture_method(或capture_at_checkout?/capture_on_dispatch?/capture_manually?),写capture_method。任何表达「何时动钱」的新设置都必须放进Spree::CaptureMethod词汇表,而不是在旁边再加布尔;
  • 校验地址或查询目录的后台任务必须设置Spree::Current.store——否则静默使用默认值。

开放问题与后续方向

  • Market 级track_price_history:留待价格按 Market 划分设计完成后再议;
  • 购物车过期设置guest_cart_expiry_days等)仍是应用级维护事项,按店留存期延迟到有人提出需求。

延伸阅读

  • 配置使用审计:2026-08-04 多 Agent 追踪 + 对抗性验证,51 个非弃用偏好中 38 活跃 / 6 遗留 / 7 未用;
  • 先例:companyStore#company_field_enabledconfiguration.rb:47)、allow_guest_checkoutStore#guest_checkoutStore#stock_reservation_ttl_minutes(store.rb 第 98 行);
  • 5.6-6.0-single-store-promotions-payment-methods.md——本次复用的回填 + 弃用桥模式;
  • 6.0-tax-provider.md——负责tax_using_ship_address的退役;
  • 6.0-returns-exchanges-claims.md——负责退货相关设置的退役;
  • 6.0-stock-reservations.md——承载reserve_stock_on与 TTL 全局的取代说明;
  • 文档页:docs/developer/customization/configuration.mdx。

【免费下载链接】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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询