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),此时再让「按店而异的商业行为」由全局标志控制,就成了架构上的坏味道。此前company→company_field_enabled、allow_guest_checkout→guest_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_dispatch | preferred_capture_method | 'checkout' |
track_inventory_levels | preferred_track_inventory_levels | true |
stock_reservations_enabled | preferred_stock_reservations_enabled | true |
track_price_history | preferred_track_price_history | true |
show_products_without_price | preferred_show_products_without_price | false |
address_requires_phone | preferred_address_requires_phone | false |
disable_sku_validation | preferred_disable_sku_validation | false |
这些偏好都已在 spree/core/app/models/spree/store.rb 中声明,例如preference :track_inventory_levels, :boolean, default: true(store.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原本是支付方式表里的布尔列(与active、position并列),替换它的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_payments与Orders::Complete#process_payments已改为检查支付是否覆盖总额,Orders::Complete还会在 gateway 错误时向errors追加消息并失败——开着它订单也完不成,放进 Dashboard 只会是个死开关。address_requires_state直接丢弃而非迁移:它已被废弃,且只是国家自身states_required标志的重复开关。Address#state_validate现在只读国家标志;把该设置设为false而国家要求州名的店铺,其地址将从迁移后开始校验失败——应该去改国家标志。- 七个零读取的死设置在 6.0 加了弃用壳(
products_per_page、alternative_shipping_phone、show_variant_full_price、reserve_stock_on、storefront_products_path、storefront_taxons_path、storefront_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_method | payment_method.rb(resolved_capture_method)、fulfillment.rb(process_order_payments)、fulfillments/fulfill.rb | PaymentMethod 为 store 所有;fulfillment → order/cart → store |
track_inventory_levels | variant.rb、product.rb | variant → product → store |
stock_reservations_enabled | stock_reservations/reserve.rb、stock_reservations/extend.rb、stock/quantifier.rb | cart/order → store;stock_item → stock_location → store |
track_price_history | price.rb | price → variant → product → store |
show_products_without_price | product_scopes.rb | Spree::Current.store(类级 scope) |
address_requires_phone | address.rb、addresses/phone_validator.rb | Spree::Current.store(无 store 关联) |
disable_sku_validation | variant.rb | variant → product → store |
Product.store是optional: 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)
- 在
Spree::Store上新增八个偏好,默认值与全局一致; - 按上表重指读取点;
Address与 product scope 读Spree::Current.store; - 在
spree/core/lib/spree/core/configuration.rb中把八个全局配置与死设置标记为deprecated:(访问时经Spree::Deprecation告警); - 交付
spree:store_settings:backfill_from_config并加入 5.6→6.0 升级清单(与sanitize_rich_text同机制); StockReservation.ttl_for去掉全局读取。
回填任务(backfill)的实现与坑
任务实现在 spree/core/lib/tasks/store_settings.rake。MOVED_SETTINGS哈希把全局名映射到 store 偏好名与默认值(注意两个改名项:company→company_field_enabled、default_stock_reservation_ttl_minutes→stock_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_methods把auto_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):暴露
- Admin API v3 store serializer 与 permitted params 加入八个偏好,重新生成类型;
- Dashboard 设置页在自然分区(payments、inventory、catalog、checkout)浮现新开关;
- 文档:将八个设置移入 store-settings 文档(当前仓库对应页面为 docs/developer/customization/configuration.mdx,保持 commerce / application 两组结构)。
Phase 3(6.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 未用;
- 先例:
company→Store#company_field_enabled(configuration.rb:47)、allow_guest_checkout→Store#guest_checkout、Store#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),仅供参考