Spree 5.6 促销与支付方式单店化改造:从 Join 表到 store_id 外键的完整迁移方案

发布时间:2026/9/14 10:58:49
Spree 5.6 促销与支付方式单店化改造:从 Join 表到 store_id 外键的完整迁移方案 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::StorePromotionspree_promotions_storespromotion_id、store_id、timestamps(promotion_id, store_id)Spree::StorePaymentMethodspree_payment_methods_storespayment_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.rbstore_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_storestore_id为空时从Spree::Current.store赋值。store 必填——不允许 nil store也不做逐模型自动赋值之外的兜底。concern 的ensure_store从Spree::Current.store填充 store每个模型再声明validates :store, presence: true遵循文档化的disable_store_presence_validation逃生门。两者合力当前 store 自动赋值且任何无法解析出 store 的记录会因校验失败而拒绝落库。所有入口都会显式设定 storeAPI 的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::SingleStoreResourcespree/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常量就自动 includemodule 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_manyspree/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 促销 APIpromotion_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 URLPaymentMethod中原来取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 Factoriespromotion_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.rbclass 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取最早的挂载记录为 ownerPayment 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_storejoins(: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.05.6 中store_id保持可空对齐 5.5 的 Product 迁移6.0 清理迁移在数据干净后强制非空StoreScopedResource→ 弃用由SingleStoreResource替换concern 标记弃用两个模型改为 includeSpree::SingleStoreResourceFKfor_storeensure_store 不可变性并各自声明validates :store, presence: trueStoreScopedResource在 6.0 清理中整体移除支付方式 owner 平票规则 → 最小store_idjoin 表没有时间戳「最早创建」不可得取最小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.rbspree/core/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rbspree/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),仅供参考