Spree 5.6 促销与支付方式单店化改造:从 Join 表到 store_id 外键的完整迁移方案
【免费下载链接】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 5.6 的多店架构收尾工作中,Spree::Promotion与Spree::PaymentMethod是最后两个仍以多店(multi-store)模型存在的核心资源。本文基于仓库中的计划文档 5.6-6.0-single-store-promotions-payment-methods.md 展开,完整讲解这次「单店化(single-store ownership)」改造的设计决策、目标模型形态、数据库迁移、数据回填(backfill)流程与部署顺序,并结合当前仓库中已落地的源码(模型、迁移文件、rake 任务)逐一印证实现细节。读完本文,你将掌握 Spree 中「从 has_many :through 关联迁移到 belongs_to 外键」这一类架构改造的完整套路,以及多店数据丢失风险的兜底策略。
计划文档的状态标注为:5.6 阶段已实现(模型、迁移、桥接、回填、控制器);6.0 清理待执行。该改造复刻了 5.5 版本中Spree::Product的单店化迁移(参考计划文档 6.0-channels-catalogs-b2b.md),并将历史遗留的「跨店共享」行为下沉到spree_multi_store扩展中。
一、改造背景:最后的多店资源
改造前,Promotion 与 Payment Method 通过has_many :through中间表建模多店归属,而其他所有按店作用域划分的资源——包括 5.5 版本起改造的Spree::Product——都已改用直接的belongs_to :store外键。计划文档中给出的旧形态代码:
# Spree::Promotion has_many :store_promotions, class_name: 'Spree::StorePromotion' has_many :stores, class_name: 'Spree::Store', through: :store_promotions # Spree::PaymentMethod has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod', inverse_of: :payment_method has_many :stores, class_name: 'Spree::Store', through: :store_payment_methods两张中间表的结构如下(对应模型 spree/core/app/models/spree/store_promotion.rb 与 spree/core/app/models/spree/store_payment_method.rb):
| 模型 | 表 | 列 | 唯一索引 |
|---|---|---|---|
Spree::StorePromotion | spree_promotions_stores | promotion_id、store_id、timestamps | (promotion_id, store_id) |
Spree::StorePaymentMethod | spree_payment_methods_stores | payment_method_id、store_id | (payment_method_id, store_id) |
旧的Spree::StoreScopedResourceconcern 提供基于 join 表的查询:
scope :for_store, ->(store) { joins(:stores).where(Store.table_name => { id: store.id }) } before_validation :set_default_store, if: :new_record? # set_default_store 在通过关联为空时把 Spree::Store.default 推入 through 关联Spree::Store侧声明反向关联:has_many :promotions, through: :store_promotions和has_many :payment_methods, through: :store_payment_methods。
另一个重要背景是PaymentMethod是 STI 模型(Spree::Gateway、Spree::PaymentMethod::Check、Spree::PaymentMethod::StoreCredit以及外部网关 gem)。从源码看(spree/core/app/models/spree/payment_method.rb),store_id外键落在基类spree_payment_methods表上,所有子类自然继承,不存在 STI 带来的额外复杂度。
代码中已存在迁移意图的「痕迹」——计划文档指出,Spree::Api::V3::ResourceController曾经承载这样的桥接分支:
# very ugly code we need to still support for promotion/payment_method until we migrate them into single store in spree 6.0 resource.store_ids = [current_store.id] if resource.respond_to?(:store_ids) && resource.store_ids.blank? && !resource.respond_to?(:store_id)改造完成后,这段针对这两个模型的兼容分支已被删除——在当前仓库的spree/api/app/controllers/spree/api/v3/resource_controller.rb中已检索不到store_ids相关代码。
二、关键决策清单
计划文档列出了「未经讨论不得偏离」的关键决策,逐条对应如下:
- 外键单归属(Single owner via FK)。两个模型各获得一个
store_id列与belongs_to :store。不引入新的 join 模型——与 Product 不同(Product 需要ProductPublication做渠道分发,促销与支付方式没有对应的「分发」概念,外键就是全部)。计划文档在 2026-07-23 修订中补充:单店 FK 立场不变,但支付方式的「分发」概念被Spree::PaymentMethodRule(见 6.0-payment-method-rules.md)以渠道/市场/总额/客户分组的资格规则形式取代,而非通过作用域列或 join 表实现。 - 多店行为移入
spree_multi_store扩展。has_many :stores/store_ids访问器作为弃用桥接保留在核心中(返回Array(store)),真正的多店关联由spree_multi_store扩展在未被触碰的 join 表之上恢复。切分方式与 Product 一致。 - join 表在 5.6 保留、6.0 删除。
spree_promotions_stores与spree_payment_methods_stores在 5.6 迁移中原样保留,以便扩展能叠加旧关联、回填可重跑;6.0 清理迁移才删除它们。 store_id在 5.6 可空,6.0 才null: false。与 Product 迁移(5.5 中store_id可空)保持一致:5.6 的列可空,未跑回填的部署不会因校验失败而报错;6.0 清理迁移在确认数据干净后强制非空。- 用
SingleStoreResource替换StoreScopedResource。旧StoreScopedResource(基于joins(:stores)的多对多for_store)标记弃用,并从两个模型中移除include;取而代之的是Order、GiftCard、Channel等模型已在使用的Spree::SingleStoreResource——它提供基于外键的for_store作用域、store 一旦设定不可更改校验,以及自 #14231 起的全局before_validation :ensure_store(store_id为空时从Spree::Current.store赋值)。 - store 必填——不允许 nil store,也不做逐模型自动赋值之外的兜底。concern 的
ensure_store从Spree::Current.store填充 store;每个模型再声明validates :store, presence: true(遵循文档化的disable_store_presence_validation逃生门)。两者合力:当前 store 自动赋值,且任何无法解析出 store 的记录会因校验失败而拒绝落库。所有入口都会显式设定 store:API 的ResourceController#build_resource、旧 admin 的ResourceController#ensure_current_store、factories、seeds 与示例数据。 - 读写字段对称性保留。序列化器与控制器继续暴露/接受相同字段;唯一变化是复数
store_ids(数组)弃用,改用单数store_id。上述 API v3 控制器的桥接分支删除。 - 多店数据丢失的「大嗓门」信号。回填时只要某条记录挂在一个以上的 store 上,就通过
Spree::Deprecation逐条告警,外加 rake 任务摘要中的聚合计数,让依赖跨店共享的商家明确知道需要安装spree_multi_store。
三、目标模型形态(已在 5.6 落地)
3.1SingleStoreResourceconcern
计划文档描述的目标状态如下:
# Spree::Promotion / Spree::PaymentMethod include Spree::SingleStoreResource # for_store + ensure_store + 不可变性 belongs_to :store, class_name: 'Spree::Store' validates :store, presence: true# Spree::SingleStoreResource (in core, shared) before_validation :ensure_store, unless: :store_id? # self.store ||= Spree::Current.store validate :ensure_store_association_is_not_changed # store_id 一旦持久化即冻结 scope :for_store, ->(store) { where(store_id: store.id) }当前仓库中的实现 spree/core/app/models/concerns/spree/single_store_resource.rb 与之完全一致,并且 concern 自身就声明了belongs_to :store。落到具体模型上:
- spree/core/app/models/spree/promotion.rb 第 5 行
include Spree::SingleStoreResource; - spree/core/app/models/spree/payment_method.rb 中同时存在
include Spree::SingleStoreResource、validates :store, presence: true与belongs_to :store, class_name: 'Spree::Store'。
一个容易踩坑的细节:Spree.base_class.belongs_to_required_by_default为false,所以belongs_to :store本身不会自动校验存在性——真正兜底的是逐模型的validates :store, presence: true(与其他所有单店资源一致的写法)。disable_store_presence_validationpreference(见 spree/core/lib/spree/core/configuration.rb 第 53 行,default: false, deprecated: true)是文档化的逃生门,供数据导入与回填窗口期(既有行在 rake 任务执行前store_id为 NULL)使用。
3.2 旧多店桥接:LegacyMultiStoreSupport
与Spree::Product::LegacyMultiStoreSupport完全镜像——弃用告警 +Array(store)语义,只要spree_multi_store扩展没有定义SpreeMultiStore常量就自动 include:
module Spree::Promotion::LegacyMultiStoreSupport included do def stores Spree::Deprecation.warn( 'Spree::Promotion#stores is deprecated. Use Spree::Promotion#store instead. ' \ 'Install spree_multi_store to keep multi-store promotions.' ) store ? [store] : [] end def store_ids Spree::Deprecation.warn('...') store_id ? [store_id] : [] end def stores=(values) Spree::Deprecation.warn('...') self.store = Array(values).compact.first end def store_ids=(ids) Spree::Deprecation.warn('...') self.store_id = Array(ids).compact_blank.first end end endSpree::PaymentMethod::LegacyMultiStoreSupport是同样的 concern。这些桥接让 API 控制器的store_ids=分支在弃用期内继续可用——虽然按计划该分支被删除、直接依赖store_id。
3.3Spree::Store侧关联
through 关联被替换为直接has_many(spree/core/app/models/spree/store.rb 中当前即为目标形态):
# before has_many :store_promotions, class_name: 'Spree::StorePromotion' has_many :promotions, through: :store_promotions, class_name: 'Spree::Promotion' has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod' has_many :payment_methods, through: :store_payment_methods, class_name: 'Spree::PaymentMethod' # after has_many :promotions, class_name: 'Spree::Promotion', dependent: :nullify has_many :payment_methods, class_name: 'Spree::PaymentMethod', dependent: :nullifydependent: :nullify(而非:destroy)与既有的Store has_many :products一致,且是正确性所必需的:在直接has_many下,清空集合(store.promotions = [],测试辅助清理中会用到)会对被移除的记录应用dependent策略。:destroy会级联触发Promotion的before_destroy :not_used?守卫,对任何已被使用的促销直接抛错;:nullify只清空外键,正是「解绑但不删业务记录」的预期行为。
store.promotions/store.payment_methods对所有调用方保持不变——Order#payment_methods、促销处理器(Coupon、Cart、FreeShipping、Page)、admin/API 控制器都经由它们读取,无需任何改动。
3.4 控制器与序列化器
Spree::Api::V3::ResourceController:删除resource.store_ids = [current_store.id]桥接分支。基类的build_resource在store_id为空时已会执行resource.store = current_store,现在即可满足两个模型的必填 store 校验。- Admin 促销 API(
promotion_includes):从 includes 数组中移除:stores。 - Admin 支付方式 API:原先用于查询已安装类名的
joins(:store_payment_methods).where(spree_payment_methods_stores: …)改为where(store_id: current_store.id).pluck(:type)或简单的current_store.payment_methods.pluck(:type)。 - 序列化器:无字段变更——促销与支付方式的序列化器当前都不暴露
stores/store_ids,故无内容可删(若日后发现有 admin 序列化器暴露store_ids,替换为store_id)。 - Webhook URL:
PaymentMethod中原来取stores.first的 Webhook URL 改为直接使用store。当前源码 spree/core/app/models/spree/payment_method.rb 第 157 行即为"#{store.url_or_custom_domain}/api/v3/webhooks/payments/#{prefixed_id}"。
3.5available_for_store?重写
PaymentMethod#available_for_store?(store)原先检查store_ids.include?(store.id),重写为单字段比较。当前实现(spree/core/app/models/spree/payment_method.rb 第 279 行起):
def available_for_store?(store) return true if store.blank? store_id == store.id end3.6 Factories
promotion_factory与payment_method工厂原先向promotion.stores推值,改为直接用store { Spree::Store.default || association(:store) }赋外键。
四、数据库迁移:两个「加列」迁移
迁移遵循 Product 模式(20260601000002_add_store_id_to_spree_products.rb),不加 FK 约束、不加默认值、回填前保持可空。仓库中的实际迁移文件:
spree/core/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb:
class AddStoreIdToSpreePromotions < ActiveRecord::Migration[7.2] # NOTE: After running this migration, existing promotions have +store_id IS NULL+ # and are invisible to +Promotion.for_store+. Run the backfill immediately to # copy ownership from the legacy +spree_promotions_stores+ join table: # # bundle exec rake spree:upgrade:populate_single_store_associations def change add_reference :spree_promotions, :store, null: true, if_not_exists: true end endspree/core/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb 结构相同,只是目标表换成spree_payment_methods。spree_promotions_stores与spree_payment_methods_stores两张 join 表不受触碰。
关键窗口期:迁移之后、回填之前,所有促销/支付方式的
store_id都是 NULL,对for_store不可见。回填必须在 migrate 之后立即执行,与 Product 的处理完全一致。
五、数据回填:spree:upgrade:populate_single_store_associations
单个 rake 任务(位于spree/core/lib/tasks/),幂等、批量执行,镜像spree:upgrade:populate_publications但更简单(没有 publication/channel 步骤,纯粹回填外键)。仓库中的实际实现是 spree/core/lib/tasks/single_store_associations.rake,其任务描述(desc)本身就是一份精炼的运行手册:
Populates +spree_promotions.store_id+ and +spree_payment_methods.store_id+ from the legacy +spree_promotions_stores+ / +spree_payment_methods_stores+ join tables. Idempotent — re-running skips records that already have a +store_id+. Run once after upgrading to Spree 5.6+. Multi-store merchants must install +spree_multi_store+ before running; without it, a record shared across several stores keeps only one owner (promotions: the earliest +spree_promotions_stores+ row by +created_at+; payment methods: the lowest +store_id+, since that join has no timestamps) and the other stores lose the shared record. Each shared record is logged so the loss is visible.任务核心逻辑(源码节选):
task populate_single_store_associations: :environment do shared = Hash.new(0) if ActiveRecord::Base.connection.table_exists?(Spree::StorePromotion.table_name) Spree::Promotion.where(store_id: nil).find_each do |promotion| store_ids = Spree::StorePromotion.where(promotion_id: promotion.id).order(:created_at, :store_id).pluck(:store_id) next if store_ids.empty? if store_ids.size > 1 shared[:promotions] += 1 Spree::Deprecation.warn( "Promotion #{promotion.id} was shared across #{store_ids.size} stores; " \ "assigning it to store #{store_ids.first}. Install spree_multi_store to keep sharing." ) end promotion.update_column(:store_id, store_ids.first) end end if ActiveRecord::Base.connection.table_exists?(Spree::StorePaymentMethod.table_name) # +with_deleted+: PaymentMethod 是 paranoid 模型,软删除行若不处理 # 会被跳过、永远停留在 store_id NULL。 Spree::PaymentMethod.with_deleted.where(store_id: nil).find_each do |payment_method| store_ids = Spree::StorePaymentMethod.where(payment_method_id: payment_method.id).order(:store_id).pluck(:store_id) next if store_ids.empty? if store_ids.size > 1 shared[:payment_methods] += 1 Spree::Deprecation.warn(...) end payment_method.update_column(:store_id, store_ids.first) end end if shared.values.sum.positive? puts " #{shared[:promotions]} promotion(s) and #{shared[:payment_methods]} payment method(s) " \ "were shared across stores — only the owner store keeps them unless spree_multi_store is installed." end # ...(见下) end实现中值得注意的几个细节(比计划文档中的伪代码更进一步):
- owner 选择规则不对称:Promotion 的 join 表有
created_at,取最早的挂载记录为 owner;Payment Method 的 join 表没有时间戳,只能确定性地取最小store_id。这也是「Resolved Decisions」中明确的决议:确定且可重跑。 - 软删除记录也要回填:
PaymentMethod使用了acts_as_paranoid(源码 spree/core/app/models/spree/payment_method.rb 顶部可见),因此回填用with_deleted查询,否则软删除行会被跳过、永久保持 NULL。 - join 表可能不存在时优雅降级:两处都用
table_exists?守卫,缺表则打印提示并跳过对应分支——使任务在spree_multi_store已删除/未安装的边缘场景下依然可运行。 - position 重编号(任务尾部附加步骤):源码第 64–73 行按 store 分组对
PaymentMethod的position从 1 开始重新编号。原因是升级前position是全表编号,而模型现在acts_as_list scope: :store_id(见 spree/core/app/models/spree/payment_method.rb 顶部注释:缺少该 scope 会让 position 分配和排序在所有store 之间串行)。稀疏、非 1 起头的列表会扰乱acts_as_list的移动操作,重编号是幂等的。
对于真正在一个促销/支付方式上共享给 N 个 store 的商家:最早(促销)/最小store_id(支付方式)的挂载记录成为 owner,其余行仍然留在 join 表中。每条共享记录在回填时触发逐条Spree::Deprecation告警,任务摘要给出聚合计数,丢失「不可能被忽略」。安装spree_multi_store可恢复完整的has_many :stores视图;不安装的话,其余 store 将失去该共享记录(同时作为 breaking change 写入升级指南,附带补救建议:按 store 复制记录,或安装扩展)。
六、部署顺序与 6.0 清理
部署顺序与 channels 升级完全同形:
- 部署代码(模型、concerns、迁移、控制器/序列化器修改)。
bundle exec rake db:migrate→ 新增可空store_id列。回填前促销/支付方式对for_store不可见。bundle exec rake spree:upgrade:populate_single_store_associations→ 回填外键,资源重新可见。
6.0 清理(独立周期):待spree_multi_store扩展发布、弃用窗口结束后,跟进迁移将 (a) 将store_id设为null: false,(b) 删除spree_promotions_stores/spree_payment_methods_stores两张 join 表,并移除不再使用的StorePromotion/StorePaymentMethod模型、LegacyMultiStoreSupport桥接和弃用的StoreScopedResourceconcern。从源码结构看,过渡状态已经就位:spree/core/app/models/spree/store_promotion.rb 的类注释明确写着「Superseded by the single-storePromotion#storeFK in 5.6; retained only so thespree_multi_storeextension can restorehas_many :storesand so the backfill task can read historic attachments. Dropped in 6.0.」。
spree_multi_store扩展的职责
- 定义
SpreeMultiStore常量,使核心跳过LegacyMultiStoreSupport的 include; - 在两个模型及
Store上重新声明has_many :stores, through: :store_{promotions,payment_methods}; - 让
StorePromotion/StorePaymentMethod模型与 join 表继续存活; - 恢复原始多店
for_store(joins(:stores))与默认 store 赋值语义。
这与该扩展对Product has_many :stores已履行的契约相同。
七、对当前开发的约束(迁移落地前同样生效)
计划文档要求即便在实现完成前也要遵守,避免扩大需要迁移的面向:
- 不要为促销或支付方式新增
store_ids/stores用法。尽量基于单数store/store_id写代码,或走current_store.promotions/current_store.payment_methods,这样代码在改造前后都能透明存活; - 不要为这两个模型新增
StoreScopedResource那种基于 through 的for_store调用方;优先current_store.<assoc>; - 新的 API/序列化器代码不得暴露
store_ids(这些资源目前没有公开该字段,保持现状); - 触碰
ResourceController的store_ids=桥接时,在迁移落地前保留它——它是文档化的接缝。
八、已决议的开放问题
这些原本是开放问题,现已敲定:
- 多店数据丢失信号 → 「大嗓门」:回填时对任何 >1 store 挂载的记录逐条
Spree::Deprecation告警 + rake 摘要中的聚合计数 + 升级指南备注; null: false时机 → 推迟到 6.0:5.6 中store_id保持可空(对齐 5.5 的 Product 迁移);6.0 清理迁移在数据干净后强制非空;StoreScopedResource→ 弃用,由SingleStoreResource替换:concern 标记弃用,两个模型改为 includeSpree::SingleStoreResource(FKfor_store+ensure_store+ 不可变性)并各自声明validates :store, presence: true;StoreScopedResource在 6.0 清理中整体移除;- 支付方式 owner 平票规则 → 最小
store_id:join 表没有时间戳,「最早创建」不可得,取最小store_id确定且可重跑。
九、参考文件索引
- 计划文档:docs/plans/5.6-6.0-single-store-promotions-payment-methods.md
- 参考迁移(单店 Product、
LegacyMultiStoreSupport、spree_multi_store切分):docs/plans/6.0-channels-catalogs-b2b.md;支付方式资格规则:docs/plans/6.0-payment-method-rules.md - 核心实现:
- spree/core/app/models/concerns/spree/single_store_resource.rb — 共享 concern 本体
- spree/core/app/models/spree/promotion.rb / spree/core/app/models/spree/payment_method.rb — 两个被改造模型
- spree/core/app/models/spree/store.rb —
dependent: :nullify关联 - spree/core/app/models/spree/store_promotion.rb / spree/core/app/models/spree/store_payment_method.rb — 保留至 6.0 的旧 join 模型
- 迁移与回填:
- spree/core/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb
- spree/core/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb
- spree/core/lib/tasks/single_store_associations.rake —
spree:upgrade:populate_single_store_associations
- 配置逃生门:spree/core/lib/spree/core/configuration.rb 中的
disable_store_presence_validation
适用前提与限制:本文描述的是当前仓库所处的过渡态——5.6 阶段(模型、迁移、桥接、回填)已实现,6.0 清理(null: false、删表、删桥接)尚待执行。如果你正在升级既有部署,务必按「migrate → 立即 backfill」的顺序操作;如果业务依赖跨店共享促销或支付方式,请先安装spree_multi_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
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考