Spree 6.0 移除 PermittedAttributes:用模型级additional_permitted_attributes钩子重构属性白名单
【免费下载链接】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::PermittedAttributes是 Spree 长期存在的一个全局可变属性白名单注册表(66 个属性列表),本是为已被移除的 Rails Admin 与 Storefront 设计的扩展入口。Spree 6.0 将其彻底删除(无弃用桥接),并引入一个位于Spree::Base上的class_attribute :additional_permitted_attributes模型级扩展钩子,让扩展在初始化器中以一行代码向指定模型追加可写属性,同时保持 API v3 控制器内联声明的扁平参数约定。读完本文你将掌握:该机制被删除的完整理由、新钩子的语义与源码实现、ResourceController的并集(union)接线方式、以及从旧注册表迁移的完整 10 步路径。
背景:为什么删除一个"已经快死掉"的注册表
Spree::PermittedAttributes是一个全局可变的白名单,共维护 66 个属性列表,最初的设计目标是让宿主应用(host app)与扩展从初始化器里追加可写属性,而无需装饰(decorate)控制器。但它的存在前提在 Spree 6.0 已经瓦解:
- 消费端消失:该注册表是为 Rails Admin 与 Storefront 构建的,而这两者在 6.0 中均已移除;
- API v3 自带约定:v3 控制器在自身内部内联声明允许属性,采用扁平参数(flat-params)约定,见 6.0-admin-api.md;
- 商家数据改由 Custom Fields 承载:商户新增的数据字段由 Custom Fields 提供,且自 5.6 起支持排序与过滤,见 6.0-store-scoped-custom-field-definitions.md。
从代码实际使用情况看,该注册表已接近死亡:66 个键中只有 5 个还在代码库中被按名称读取;而建立在它之上的组合辅助模块Spree::Core::ControllerHelpers::StrongParameters提供的 6 个组合 helper(如permitted_checkout_attributes、permitted_product_attributes)没有任何调用方。
注册表的三个消费路径(全部被移除)
- 5 处显式 Store API 读取:
wishlists、wishlist_items、carts/payment_sessions、carts/items、customer/payment_setup_sessions;另有Spree::Carts::AddItem通过展平line_item_attributes来过滤传入的 options 键。 ResourceController#permitted_attributes中的名称推断回退(inference fallback):通过模型名经public_send推导属性键。但几乎所有到达该回退的 Admin 控制器都是只读的;真正有写动作的四个控制器(customers/addresses、orders、admin_users、stock_transfers)都自己定义了内联 permit,从不触碰它。ControllerHelpers::StrongParameters:委托全部 66 个键并定义组合 helper,零调用方;其组合 helper 描述的嵌套属性载荷形态(bill_address_attributes、payments_attributes)正是 v3 以扁平参数规则明确拒绝的。
注册表的失败模式:三处重复声明
注册表还有一个被证实过的失效模式:同一属性必须在多个互不关联的位置各声明一次,例如变体(variant)属性需要在三处登记,漏掉任何一处写路径都会静默丢弃该属性——详见 6.0-duties-and-custom-fees.md 中记录的三处(three-places)问题。结论很明确:一个并非真正单一事实来源(single source of truth)的全局列表,比没有列表更糟。
新钩子:Spree::Base.additional_permitted_attributes
替代方案是在Spree::Base上定义一个class_attribute,默认值为空数组:
# spree/core/app/models/spree/base.rb # Extra writable attributes contributed by extensions, appended to the v3 # controller allowlist for this resource. Core attributes belong in the # controller's own list — this exists so an extension that adds a column can # make it writable without decorating a controller: # # Spree::Product.additional_permitted_attributes += [:brand_id] # # Entries are `params.permit` fragments: bare symbols, or hashes for # collections and nested structures (`{ region_ids: [] }`). Append with `+=` # rather than assigning, so extensions don't clobber each other. class_attribute :additional_permitted_attributes, instance_writer: false, default: [].freeze对应的实际源码位于 spree/core/app/models/spree/base.rb。
扩展在初始化器中声明,与旧注册表的喂入位置完全一致:
# config/initializers/spree.rb Spree::Product.additional_permitted_attributes += [:brand_id]无需装饰器、无需注册步骤。使用class_attribute带来的关键语义:
- 按类存储 + 继承:向
Spree::Product追加不会影响Spree::Variant或Spree::Base; - STI 子类可在类体中直接赋值:核心 STI 子类通过
self.additional_permitted_attributes = [product_ids: []]声明自己的值,不会扰动父类; - 条目是
params.permit片段:裸 Symbol 表示标量属性,Hash 表示集合与嵌套结构(如{ region_ids: [] })。
必须+=,永远不要=
计划文档与升级指南都反复强调:追加一律使用+=,绝不用=——直接赋值会覆盖另一个扩展已经追加的内容。此外,用<<原地修改会抛出FrozenError:默认值是冻结的共享数组(default: [].freeze),原地改动会把你的属性泄漏到每一个其他模型上。
Controller 接线:两级拆分完成并集
ResourceController(源码见 spree/api/app/controllers/spree/api/v3/resource_controller.rb)采用了"子类声明自己的列表、基类追加扩展贡献"的两级拆分:
def permitted_params normalize_params(params.permit(*permitted_attributes)) end # Resource list + extension contributions. Subclasses override # `resource_permitted_attributes`, never this. def permitted_attributes resource_permitted_attributes + model_additional_permitted_attributes end def resource_permitted_attributes raise NotImplementedError, 'Subclass must implement resource_permitted_attributes or permitted_params' end def model_additional_permitted_attributes return [] unless respond_to?(:model_class, true) model = model_class return [] unless model.respond_to?(:additional_permitted_attributes) Array(model.additional_permitted_attributes) rescue NotImplementedError [] end三条使用守则:
- 覆盖
permitted_attributes会静默丢失扩展属性:该方法是并集本身,正确的覆盖点是resource_permitted_attributes(返回纯列表)。没有机制强制这一点,因此被明确写入了CLAUDE.md供开发者注意; - 不要为了拿到并集而把控制器卷入
normalize_params:直接 splat 就能获得同样结果,且不改动参数语义。resolve_prefixed_ids会递归进入嵌套 Hash,把匹配/\A[a-z]+_[a-zA-Z0-9]+\z/的任何*_id键值解码——这会破坏网关preferences与商户metadata这类不透明值。计划文档给出的例子:Stripe 的webhook_endpoint_id值为we_1MqJ8bLkdIwHu7ixIhqHQfbo,一旦被解码会变成2345514981514645561106。携带网关preferences或商户metadata的控制器绝不能被卷入规范化; model_additional_permitted_attributes中的两个守卫都是承重的(load-bearing):控制器未必定义model_class(respond_to?检查加NotImplementedErrorrescue),且Spree.base_class可被宿主覆盖,因此该钩子并不保证一定从Spree::Base继承下来。
直接覆盖permitted_params的控制器
对于完全绕过上述路径、自行覆盖permitted_params的控制器,需要把扩展属性 splat 进自己的params.permit调用——这是一行式改动,不改变该控制器既有的规范化行为:
def permitted_params params.permit(*model_additional_permitted_attributes, :name, :active) end两个凭据类端点被刻意排除在外:api_keys与invitations——它们的参数属于授权面(authorization surface),不是可扩展的资源数据。
四个既有规则基类的收敛
Spree::PromotionRule、Spree::PromotionAction、Spree::DeliveryMethodRule与Spree::CommissionRule此前各自定义了内容完全相同的additional_permitted_attributes默认值。一旦Spree::Base承载该钩子,这四份冗余定义即被删除,由继承默认值接管,其 STI 子类中的覆盖保持不变。
但需要注意:消费这些规则类的控制器走的是另一套机制。PromotionsController#subclassed_collection_attributes与配送方式规则等价物会遍历一个子类注册表,跨所有已注册类型做并集——因为控制器写的是 STI 基类,无法预知请求针对哪个子类。新的ResourceController并集只询问单一model_class。两者可以共存:注册表遍历面向多态写入,模型钩子面向具体资源。这些控制器中冗余的respond_to?守卫可随本次改动一并移除。
源码中的 STI 子类赋值示例
从仓库实际代码可见,各 STI 子类在类体中直接赋值:
- 促销规则类(如 promotion/rules/product.rb、
user.rb、category.rb):self.additional_permitted_attributes = [product_ids: []]/[customer_ids: []]/[category_ids: []]; - 促销动作类(如 promotion/actions/create_adjustment.rb):
self.additional_permitted_attributes = [calculator: [:type, { preferences: {} }]]; - 配送方式规则类(delivery_method_rules/excluded_products_rule.rb):
self.additional_permitted_attributes = [product_ids: []]。
这就是计划文档所述"把既有的八处方法式覆盖统一转换为赋值,让一个机制同时覆盖核心声明与扩展追加"的落地形态。
旧用途的替代关系总览
| 旧用途 | 6.0 替代方案 |
|---|---|
| 商家新增数据字段 | Custom Fields(5.6 起可排序/可过滤) |
| 扩展为核心模型增加数据库列 | 模型上的additional_permitted_attributes |
| 扩展新增 STI 规则/动作类型 | additional_permitted_attributes+ 注册表注册(不变) |
| 核心属性列表 | 控制器内联params.permit(不变) |
完整迁移路径(10 步)
- 新增钩子:在
Spree::Base上定义additional_permitted_attributes,删除四个规则基类上的冗余副本。 - 接入并集并移除推断回退:将并集接进
ParamsNormalizer#normalize_params,把ResourceController#permitted_attributes中的推断回退替换为NotImplementedError。跑一遍 API 测试套件:任何此前静默依赖推断的写控制器现在都会响亮失败——这正是目的所在。实际中恰好有一个案例:admin/tax_categories_controller拥有完整 CRUD 却无自己的白名单,现在显式声明[:name, :tax_code, :description, :is_default]。 - 内联 5 处 Store API 读取:
wishlists、wishlist_items、carts/payment_sessions、carts/items、customer/payment_setup_sessions。每个列表都很短,逐字移入控制器即可。 - 处理
Carts::AddItem:它读取line_item_attributes以过滤传入的 options 键,需要一个不属于控制器的存放处。落地为Spree::LineItem上的常量,由 workflow 与carts/items_controller共同读取。实现中已确认:该列表现为Spree::LineItem::WRITABLE_ATTRIBUTES,workflow 通过::Spree::LineItem.additional_permitted_attributes.flat_map { |a| a.is_a?(Hash) ? a.keys : a }读取(见 spree/core/app/workflows/spree/carts/add_item.rb)。值得一提的细节:旧代码执行line_item_attributes.flatten,会把字面量{ metadata: {} }哈希也当作 options 键——毫无意义,只是靠后续的delete_if丢弃 nil 才幸存下来;新常量只保留真正起作用的三个标量键。 - 转换跳过
normalize_params的params.permit调用点:这些调用点默认退出并集,需要显式转换才能让扩展属性抵达;已经走规范化的控制器无论列表如何构建都无需改动。 - 删除
ControllerHelpers::StrongParameters:连同它在两个基类控制器中的include、它的require_dependency,以及spree/core/spec/lib/spree/core/controller_helpers/strong_parameters_spec.rb测试文件。 - 删除
spree/core/lib/spree/permitted_attributes.rb及其所有require。 - 更新文档:重写 docs/developer/customization/api.mdx 的
Permitted Attributes一节;更新docs/developer/tutorial/extending-models.mdx(两处)与docs/developer/tutorial/api.mdx中的brand_id示例。 - 升级指南:在 docs/developer/upgrades/5.6-to-6.0.mdx 中新增破坏性变更条目,覆盖常量删除、helper 模块删除与
additional_permitted_attributes替代,并附 before/after 示例。 - 测试:覆盖
ResourceController的并集行为——扩展声明的属性可写、未声明的属性被丢弃;并确认当permitted_attributes与permitted_params都未定义时抛出NotImplementedError。
升级实操:grep 定位、三桶归类与持久化验证
升级指南提供了可直接落地的迁移操作。
第 1 步:找出所有调用点。常量与 helper 方法都已消失,遗漏的引用会在代码首次运行时以NameError或NoMethodError响亮暴露(但不一定在启动时):
grep -rn "PermittedAttributes" app config lib grep -rnE "permitted_[[:alnum:]_]+_attributes" app config lib第二个模式刻意放宽:helper 模块曾按注册表键各生成一个permitted_*_attributes方法,因此有数十个之多。
第 2 步:判断每个属性归属哪一类。绝大多数落入三个桶,只有最后一类才需要新钩子:
| 你原来添加的是什么 | 6.0 中放到哪里 |
|---|---|
| 商户管理的字段(文本、数字、下拉) | Custom Fields——无需代码,且可过滤/排序 |
| 你注册的 STI 类型的配置(促销规则、配送方式规则等) | 对应子类上的additional_permitted_attributes,与原来一致 |
| 你的扩展为核心模型新增的真实数据库列 | 模型上的additional_permitted_attributes |
第 3 步:把声明指向模型。声明仍留在初始化器中,只是接收者从全局注册表换成模型本身:
# Before Spree::PermittedAttributes.product_attributes << :brand_id # After Spree::Product.additional_permitted_attributes += [:brand_id]使用+=而非=;<<会因默认值冻结而抛出FrozenError。只声明属于自己的属性——重复声明控制器已允许的键(如metadata、prices)不会扩大权限,强参数对同一键保留最后一个过滤器,你的声明反而会覆盖控制器自己的。
第 4 步:修复你自己的控制器。如果你继承了 Spree v3 资源控制器并依赖模型名推断属性列表,现在必须显式声明:
class BrandsController < Spree::Api::V3::Admin::ResourceController protected def model_class Spree::Brand end # Before: no such method — the base class inferred `brand_attributes` # from the model name. Now you say what you accept. def resource_permitted_attributes [:name, :slug, :description] end end既不声明resource_permitted_attributes也不声明permitted_params,会在首次写入时抛出NotImplementedError——在测试套件中就能暴露,而不是静默放行一份过期列表。
第 5 步:验证写入真的落库。声明从未抵达控制器时会静默失败——强参数丢弃未允许的键,请求仍返回 200,列保持旧值。要断言的是保存后的记录,而非响应状态码:
patch "/api/v3/admin/products/#{product.prefixed_id}", params: { brand_id: brand.id }, headers: headers expect(product.reload.brand_id).to eq(brand.id)仓库的 resource_controller_spec.rb 正是以这种方式验证并集:临时向Spree::TaxCategory.additional_permitted_attributes追加:brand_id,断言扩展声明属性可写,测试结束后恢复原值。
边界与约束:这个钩子的适用范围
计划文档明确划定了该钩子的边界:
- 只面向扩展新增的列:核心属性留在控制器的内联
params.permit中。核心模型绝不能用这个钩子声明自己的基础属性——那等于逐个类地重新制造一个注册表; - Custom Fields 仍是商家数据的第一推荐路径:该钩子被定位为"扩展新增真实数据库列时的回退方案",而非默认的扩展故事;
- 新 v3 控制器声明
permitted_attributes(纯列表)而非覆盖permitted_params,除非控制器确实需要定制构建器——覆盖permitted_params即退出扩展并集; - 不要依赖模型名推断回退:每个写控制器必须显式陈述自己的列表;
- 正在基于
PermittedAttributes编写扩展的,应立即转向additional_permitted_attributes。
参考资料
- docs/plans/6.0-admin-api.md——v3 扁平参数约定
- docs/plans/6.0-duties-and-custom-fees.md——促成放弃全局列表的三处声明失败案例
- docs/plans/6.0-delivery-method-rules.md 与 docs/plans/6.0-extendable-validations.md——
additional_permitted_attributes先例与"装饰器仍是加法路径"的立场 - 被移除代码:spree/core/lib/spree/permitted_attributes.rb、spree/core/lib/spree/core/controller_helpers/strong_parameters.rb(6.0 起不再存在于仓库中)
【免费下载链接】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),仅供参考