关于 spree-decorators
面向 Rails 应用中扩展 Spree 类的开发技能,采用 Ruby 装饰器。内容说明模型与控制器装饰器的适用范围,提供生成器和实现模式,并指出工作流及 API 更改的替代方式。
主要功能
- 01生成模型和控制器装饰器
- 02添加模型关联和验证
- 03扩展作用域与辅助方法
- 04调整 API 控制器钩子
- 05允许属性并配置筛选
- 06识别应使用其他扩展点的场景
适用场景
- 01为 Spree 模型添加字段关联
- 02定义模型作用域或只读辅助方法
- 03缩小 API 端点返回的记录范围
- 04为业务行为选择工作流钩子而非装饰器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-decorators安装后在 Claude Code 中输入/spree-decorators确认已加载。
详细介绍
Spree 装饰器
命令使用 Spree CLI(
spree …)。在没有 CLI 的传统 Rails 应用中,请在应用根目录运行bin/rails g …。路径假定 Rails 应用位于server/。
装饰器使用 Ruby 的 Module#prepend,按照 Spree 文件名约定并借助两个生成器,从应用中修改现有 Spree 类。这会让你依赖 Spree 内部实现——仅将其用于结构性更改(关联、验证、作用域、只读辅助方法、收窄控制器作用域),不要用于行为更改。
先选择合适的工具
| 使用场景 | 使用此项,而不是装饰器 |
|---|---|
| 否决某项操作(添加到购物车限制、结账规则、取消/退货政策) | 工作流 validate 钩子 — spree-workflows |
| 为税费 / 促销计算提供数据 | 工作流上下文钩子 — spree-workflows |
| 在购物车/订单/履约/支付操作的同时执行某项操作 | 工作流生命周期钩子 — spree-workflows |
| 保存后响应 / 同步到 ERP / 发送通知 | 事件订阅器 — spree-events-webhooks |
| 替换某个流程的计算方式 | Spree.dependencies — spree-dependencies |
| 更改 API 响应结构 | 通过 Spree.api.*_serializer 使用序列化器子类 — spree-dependencies |
| 接受 API 写入中的额外属性 | Model.additional_permitted_attributes += [...](见下文) |
让字段可通过 q[...] 筛选 | Spree.ransack.add_attribute(Spree::Product, :brand_id) |
| 无需迁移即可存储额外值 | 自定义字段(product.set_custom_field('ns.key', value))— spree-catalog |
| 添加新端点 | 新建 ResourceController 子类 — spree-resource |
| 管理界面更改 | Dashboard 插件 / 插槽 — spree-dashboard-plugins |
| 为模型添加关联、验证、作用域或辅助方法 | 装饰器(此技能) |
收窄现有端点的 scope,替换其 serializer_class | 控制器装饰器(此技能) |
切勿装饰业务动词(Order#finalize!、Order#cancel、Payment#capture!、Fulfillment#finalize!):它们是已弃用的空壳,会委托给工作流,而实际流程(Spree::Carts::Complete、Spree::Orders::Cancel、Spree::Payments::Capture)不会调用它们。状态转换也没有可供挂钩的状态机回调——请使用工作流钩子。
模式
# server/app/models/spree/product_decorator.rb
module Spree
module ProductDecorator
def self.prepended(base)
# class-level: associations, validations, scopes, class_attributes — called on base
end
# instance methods here; call super to reach the original
end
Product.prepend(ProductDecorator)
end
装饰器由 config/application.rb 中的 config.to_prepare 块加载(由 Spree 安装程序添加),该块会对 app/**/*_decorator*.rb 执行 glob 匹配——因此 prepend 会在启动时以及每次开发环境重新加载后运行。如果应用中没有此块,请添加它:Zeitwerk 不会自动加载未被引用的装饰器,因此装饰器会静默地不生效。
生成器
spree generate model_decorator Spree::Product
# → server/app/models/spree/product_decorator.rb
spree generate controller_decorator Spree::Api::V3::Admin::ProductsController
# → server/app/controllers/spree/api/v3/admin/products_controller_decorator.rb
(不使用 CLI 时,使用 bin/rails g spree:model_decorator …。)模型生成器会对名称执行 classify,该操作会将名称变为单数形式——对于名称为复数的类(Spree::Exports::Products),请手动编写文件。
模型装饰器模式
添加关联
先创建迁移——Spree 风格是不使用外键约束:
spree generate migration AddBrandIdToSpreeProducts brand_id:bigint:index
class AddBrandIdToSpreeProducts < ActiveRecord::Migration[8.1]
def change
add_column :spree_products, :brand_id, :bigint
add_index :spree_products, :brand_id
end
end
module Spree
module ProductDecorator
def self.prepended(base)
base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
base.has_many :videos, class_name: 'Spree::Video', dependent: :destroy
base.additional_permitted_attributes += [:brand_id] # writable via Admin API (see below)
end
end
Product.prepend(ProductDecorator)
end
在 Spree 模型中,belongs_to 默认是必需的——添加 optional: true,否则每个现有产品都会报错“Brand must exist”。始终传入 class_name(字符串——延迟解析,可避免加载顺序问题),并在 has_many 上设置 dependent:。
验证、作用域、类方法
module Spree
module ProductDecorator
def self.prepended(base)
base.validates :external_id, uniqueness: { scope: :store_id }, allow_nil: true # plus a DB index
base.scope :recently_added, -> { where(created_at: 30.days.ago..) }
base.scope :tagged_featured, -> { where("metadata->>'featured' = ?", 'true') } # jsonb on PostgreSQL
base.extend ClassMethods
end
module ClassMethods
def search_by_name(query)
where('LOWER(name) LIKE ?', "%#{sanitize_sql_like(query.downcase)}%")
end
end
end
Product.prepend(ProductDecorator)
end
带有元数据的模型有一个 metadata JSON 列(public_metadata / private_metadata 已不再存在;private_metadata 是已弃用的 Ruby 别名)。metadata 是内部数据,绝不会通过 Store API 暴露;对于店面可见或可筛选的数据,优先使用真实列或自定义字段。JSON 运算符(->>)是 PostgreSQL 语法;MySQL/SQLite 需要使用不同的 SQL。
操作级规则(“每笔订单最多 10 件”“仅限 B2B 客户”)不是模型验证;它们应放在工作流的 validate 钩子中,这样数据迁移和员工修正才不会受阻。不要通过 clear_validators! 放宽核心规则;请参阅 node_modules/@spree/docs/dist/developer/customization/validations.md。
覆盖方法(调用 super)
module Spree
module ProductDecorator
def purchasable?
return false if metadata[:embargoed]
super
end
end
Product.prepend(ProductDecorator)
end
省略 super 会完全替换原方法。覆盖范围应限于只读或派生方法;任何会写入数据或转移资金的操作都应放在工作流中。
Cart 和 Order:方法位于 Spree::Purchase::* concerns 中
Spree::Cart(结账前)和 Spree::Order(已下单,不可变)通过 concerns 共享行为,包括 Spree::Purchase::Totals、::Addresses、::Taxation、::PaymentProcessing、::CheckoutSteps(仅 Cart)、::Validations 等(spree_core 中的 app/models/concerns/spree/purchase/)。装饰之前,先找到方法的定义位置(Spree::Cart.instance_method(:amount).owner)。
- 只需更改单一一侧时,装饰
Spree::Cart或Spree::Order(前置的模块位于 concern 之前,super会调用它)。 - 两侧都要更改时,装饰两个类(或直接前置到 concern:
Spree::Purchase::Totals.prepend(MyTotals)——Ruby 3 会将其传播到已包含该 concern 的类)。 - 大多数你可能想更改的 cart/order 逻辑(添加商品、总计、完成订单)都在工作流中,而非模型中——请使用钩子。
允许写入新的属性
Spree::Product.additional_permitted_attributes += [:brand_id]
Spree::LineItem.additional_permitted_attributes += [:gift_note]
- 始终使用
+=(或|=)。不要使用<<——默认值是冻结数组(会导致FrozenError);也不要使用=,否则会覆盖其他扩展添加的内容。 - 将其放在模型装饰器的
prepended中(如上所示),或放在Rails.application.config.to_prepare块中,以确保引擎模型在开发环境重新加载后仍然有效。 - API v3 控制器会将此列表与自身列表合并(
resource_permitted_attributes);Spree::PermittedAttributes已不再存在。 - 行项目:
Spree::LineItem.additional_permitted_attributes是通过加入购物车到达记录的唯一内容——可在POST /carts/:id/items请求中作为顶层参数发送,也可放在options中(Spree::Carts::AddItem)。options中的其他内容都会被丢弃;即使将id、variant_id和quantity加入许可列表,也永远无法通过这种方式设置(这些值已完成校验和定价)。
使其可筛选
# config/initializers/spree.rb
Spree.ransack.add_attribute(Spree::Product, :brand_id)
Spree.ransack.add_association(Spree::Product, :brand)
Spree.ransack.add_scope(Spree::Product, :recently_added)
如果不这样做,q[brand_id_eq]=… 会被静默丢弃(返回 200,但未筛选)。add_association 只会扩展 Admin API:Store API 仅遵循 storefront_ransackable_associations(如果购物者应能按品牌名称筛选,请在同一初始化文件中添加 Spree::Product.storefront_ransackable_associations |= %w[brand]),Seller API 则不遵循任何关联。使用 private_ransackable_attributes({ store: [...], seller: [...] })可对购物者和卖家隐藏仅供员工使用的属性。请参阅 spree-api-v3。
控制器装饰器模式(API v3)
Spree 唯一提供的控制器是 API 控制器——Spree::Api::V3::Store::*、Spree::Api::V3::Admin::*(以及 ::Seller::*)。没有可供装饰的 Rails 管理后台或 Rails 店面。请覆盖 ResourceController 提供的具名钩子——scope、serializer_class、collection_includes、scope_includes、resource_permitted_attributes、model_class——而不是 action。
缩小端点的返回范围
# server/app/controllers/spree/api/v3/admin/products_controller_decorator.rb
module Spree::Api::V3::Admin
module ProductsControllerDecorator
protected
# Chain onto super — it is already store-scoped (and ability-scoped on the
# Admin API). Replacing it leaks other stores' records.
# current_user is nil for secret-key (sk_) requests — guard with &.
def scope
return super if current_user&.spree_admin?
super.where(discontinue_on: nil)
end
end
end
Spree::Api::V3::Admin::ProductsController.prepend(Spree::Api::V3::Admin::ProductsControllerDecorator)
在 Store API 中,current_user 是已登录的客户(访客为 nil);在 Admin API 中,它是员工(使用密钥请求时为 nil)。基类中的钩子方法为 protected;装饰器中也应保持 protected。
为单个端点更换序列化器
module Spree::Api::V3::Store
module ProductsControllerDecorator
protected
def serializer_class
MyStore::DetailedProductSerializer
end
end
end
Spree::Api::V3::Store::ProductsController.prepend(Spree::Api::V3::Store::ProductsControllerDecorator)
要在所有地方更换,请改用 Spree.api.product_serializer = …(spree-dependencies)。
新操作应使用新控制器
不要通过装饰器向 Spree 的控制器添加操作。为资源控制器创建子类——你将继承分页、Ransack 筛选、带前缀的 ID、错误封装和授权:
# server/app/controllers/spree/api/v3/admin/product_audits_controller.rb
module Spree::Api::V3::Admin
class ProductAuditsController < ResourceController
scoped_resource :products # API-key scope: read_products / write_products (required on Admin controllers)
protected
def model_class = MyStore::ProductAudit
def serializer_class = MyStore::ProductAuditSerializer
def resource_permitted_attributes = [:note]
end
end
# config/routes.rb
Spree::Core::Engine.add_routes do
namespace :api, defaults: { format: 'json' } do
namespace :v3 do
namespace :admin do
resources :product_audits
end
end
end
end
spree generate api_resource 会搭建模型、序列化器、控制器和路由,详见 spree-resource。
API 装饰器必须渲染 JSON(render_error / 标准错误封装),绝不能使用 flash 或重定向。
常见问题
- 忘记调用
super——会悄无声息地替换 Spree 的逻辑。 - 在
prepended中使用实例变量(@setting = true)——它们属于模块,而非实例;请使用base.class_attribute :setting, default: true。 - 在启动时引用常量——
has_many :variants若未指定class_name: 'Spree::Variant',可能会遇到加载顺序错误。 - 文件与模块不匹配——
Spree::ProductDecorator必须放在app/models/spree/product_decorator.rb中。 - 为结账行为装饰
Spree::Order——结账前的状态属于Spree::Cart;订单只会在Spree::Carts::Complete之后创建。 belongs_to未设置optional: true——现有行可能会出现“必须存在”错误。Spree.user_class——已弃用;请装饰Spree.customer_class(默认值为Spree::Customer)或Spree.admin_user_class(Spree::AdminUser)。
组织多个装饰器
server/app/models/spree/
├── product_decorator.rb # the only file that calls prepend
└── product/
├── brand_decorator.rb # plain modules
└── seo_decorator.rb
module Spree
module ProductDecorator
include Product::BrandDecorator
include Product::SeoDecorator
end
Product.prepend(ProductDecorator)
end
Zeitwerk 会根据路径解析嵌套模块——无需使用 require / require_dependency(Zeitwerk 模式下已移除)。请注意,嵌套文件也符合 *_decorator*.rb glob,因此它们不能自行调用 prepend。
迁移回调装饰器
添加 after_save :sync_to_erp 的装饰器应改为订阅者:
# server/app/subscribers/product_sync_subscriber.rb (spree generate subscriber ProductSync)
class ProductSyncSubscriber < Spree::Subscriber
subscribes_to 'product.updated'
def handle(event)
ExternalSyncJob.perform_later(event.payload['id'])
end
end
载荷是序列化后的记录(带前缀的 id),而不是变更集。注册和异步行为详见 spree-events-webhooks。在操作中触及金额、库存或外部系统的回调,应改用工作流钩子。
延伸阅读
node_modules/@spree/docs/dist/developer/customization/decorators.md(https://spreecommerce.org/docs/developer/customization/decorators)node_modules/@spree/docs/dist/developer/customization/validations.md— 钩子、装饰器验证与注册表的比较node_modules/@spree/docs/dist/developer/customization/api.md— 自定义 API 端点node_modules/@spree/docs/dist/developer/tutorial/model-and-api.md— 产品品牌配置演练- 相关技能:
spree-workflows、spree-dependencies、spree-events-webhooks、spree-resource、spree-customization