Spree 6.0 可扩展校验体系:让 Workflow validate 钩子成为流程级规则的统一否决点
【免费下载链接】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 的工程计划docs/plans/6.0-extendable-validations.md展开,讲解该项目如何把分散在四个互不相关机制中的"扩展校验"故事收敛为一条清晰路线:以 workflowvalidate钩子作为流程级规则的推荐扩展面,补上Carts::UpsertItems与Products::*两个关键缺口,并把"拒绝"统一为ActiveModel::Errors驱动的 422 错误契约。读完你可以掌握:如何在 Spree 中注册一个校验处理器、批量改购物车商品时"部分成功 + warnings"的工作语义、购物车侧与订单侧行为差异的原因,以及地址校验注册表(Spree.validators.addresses)这类"模型形状"校验的边界在哪里。
背景:旧体系的四处碎片
在 6.0 之前,扩展 Spree 的校验逻辑面临一个碎片化的现状(计划文档 Summary 一节归纳):
- workflow
validate钩子能覆盖大部分购买流程,但批量改商品的路径完全绕过它们; - 商品写入(product writes)没有任何"否决点"(veto point),外部系统无法在商品落库前介入;
- 钩子拒绝时产生的是扁平字符串消息 + 硬编码的错误码——例如加购时触发购买上限拒绝,响应里渲染的错误码竟然是
insufficient_stock; - 模型级定制散落在四个互不相关的机制里:
Address上未文档化的 validator 注册表、password-validator 替换、各自为政的 store preferences、以及 decorators。
该计划的核心立场是:不构建通用的模型校验注册表,而是让 workflowvalidate钩子成为唯一推荐的方式,同时刻意保留"模型形状规则留在模型上"的分工。
关键决策:哪些做成钩子,哪些不做
计划文档的 Key Decisions 一节列出了不可偏离的决策,以下逐条对应其源码落地情况。
不做通用 add/remove 校验注册表
对于"新增一条校验",前置的 decorator(base.validates ...)本身够短、够表达力、且是 Rails 原生的——再做一个注册表等于给同一件事造第二套语法。对于"移除一条校验",注册表只有在校验被重新打包成命名单元时才可行,这是为一个罕见场景付出的沉重机制;放松核心规则由具名 store preference(disable_sku_validation模式)按需提供,或在 decorator 中覆写门控谓词(require_phone?模式)。这一决策在 核心实现 中得到呼应:核心规则(如协议数量条款)直接以普通方法check_quantity_rules实现,而不是伪装成钩子。
流程级规则走钩子,模型形状规则留在模型
购买上限、B2B 资格、区域策略——凡是"否决一个动作"而非"塑造一条记录"的规则,都归 workflowvalidate钩子。模型形状规则(字段必填、格式、范围)继续由模型上的validates承担,store preferences 提供核心旋钮,decorators 作为"最后手段"并显式标注该定位。
商品写入成为 workflow,但刻意保持"薄"
Products::Create、Products::Update、Products::Destroy升级为 workflow,带validate+ 生命周期钩子。"薄"的含义(Products::Create 的实现印证了这一点):嵌套数据(variants、media、prices、custom fields、categories)的赋值仍留在模型自身的 setter 和after_create/after_save回调上,无论调用方是否经过 workflow 都能拿到;workflow 存在的唯一理由是那个否决点,而它的位置正是收益所在——setter 会暂存输入、等商品存在后重放,所以validate在 insert 之前运行,一次拒绝不会留下半建成的商品。
所有服务端写入路径都路由经过这三个 workflow:Admin API v3、CSV 导入器、seeds 与样例数据。钩子总是触发,没有 bulk 旁路开关;没有注册任何处理器的安装不付出任何成本。变体随商品图走(绝大多数变体写入嵌套在商品之下),因此没有独立的变体 workflow。Products::Destroy作为 workflow 的意义在于让清理和副作用工作可以挂到after_destroy上。
地址与空购物车不建 workflow
地址写入是从多路径发起的普通 CRUD,且需求是字段形状 + 区域性的——store preferences、门控谓词和Spree.validators.addresses已经覆盖。流程级地址策略(验证、PO 箱禁令)属于carts.complete.validate与既有 checkout requirements 注册表。空购物车创建本身也不是商家需要否决的事,这延续了 decisions.md 中"普通 CRUD 不建 workflow"的既定决策。
本计划落地后的钩子面
| Key | Hooks | 状态 |
|---|---|---|
carts.add_item | validate,after_item_added | 既有 |
carts.upsert_items | validate(逐条),after_items_upserted | 新增 |
products.create | validate,after_create | 新增 |
products.update | validate,after_update | 新增 |
products.destroy | validate,after_destroy | 新增 |
订单侧孪生类Spree::Orders::UpsertItems子类化Carts::UpsertItems(与既有的Orders::AddItem孪生方式相同),因此orders.upsert_items.*这组键在 Workflow 的 inherited 规则 下免费存在——草稿订单编辑会触发 order 键。该机制的关键在于:子类继承父类声明的钩子,但以自己的键派发(Spree::Orders::AddItem触发的是orders.add_item.validate而非购物车键),并在inherited时注册自身,否则Spree.hooks.validate!会把对孪生类完全合法的钩子注册当作非法键拒绝。
拒绝契约:携带 ActiveModel::Errors 而非扁平字符串
2026-08-13 敲定的错误契约是这套体系里对扩展开发者最重要的一块。每个 workflow 实例暴露一个errors对象(绑定到 workflow 实例的ActiveModel::Errors,见 Spree::Workflow#errors),校验处理器向它追加符号化、可字段限定的错误,然后无参reject!:
class MyStore::CheckPurchaseLimit def call(workflow) return if workflow.quantity <= 10 workflow.errors.add(:quantity, :purchase_limit_exceeded, message: 'You can order at most 10 of this item.') workflow.reject! end end源码层面的支撑细节:
reject!在 workflow.rb 中无参调用时直接failure(value, errors),把整个 errors 对象带进失败 Result;reject!(message)保留为桥接,把字符串映射到:base,让既有处理器继续可用(但渲染为 base 错误,直到迁移)。- workflow 通过
extend ActiveModel::Naming/ActiveModel::Translation(workflow.rb)回答ActiveModel::Errors在符号转消息时的三个问题,使errors.add(:field, :symbol)能对着 workflow 自己的 i18n 作用域解析;read_attribute_for_validation则保证对 workflow 未暴露的属性(如:base)取值不抛异常。 render_service_error本已按ActiveModel::Errors分支、经render_validation_error渲染逐字段details,所以扩展的拒绝与模型校验 422 在形状上不可区分——控制器不再需要为它并不知情的拒绝挑选错误码。这正是计划中"停止硬编码错误码"(此前加购购买上限拒绝渲染为insufficient_stock)的落地方式。- 配套的启动期检查:
Spree.hooks.validate!(hooks.rb)把每个已注册键对照 workflow 声明的钩子做校验,之前只在 spec 中执行,现接入引擎的 after-eager-load 钩子——拼错的钩子键会在真实应用启动时失败,而不是永远静默不触发。
Carts::UpsertItems:非增量改动的唯一闸门
Carts::UpsertItems是本次计划中体量最大的一块:它成为每一个非简单增量改动的唯一闸门——带商品创建、批量商品更新、数量设定。语义要点:upsert 是设定数量(不像AddItem那样累加),数量0表示删除该商品行。
逐条 validate,与 AddItem 共用读者
实现 声明hooks :validate, :after_items_upserted,并暴露variant、quantity、metadata、items四个读者——variant/quantity/metadata与AddItem的读者同名,所以一个为carts.add_item.validate写的处理器类可以不改一字地同时注册到carts.upsert_items.validate;items提供整批已解析条目,支撑跨条规则("每单最多 20 件")。
两个值得注意的语义(实现注释 有明确说明):
- 校验是逐条的,不是批次预检:每条商品在申请落库前立即校验,因此某条的处理器运行时,同批更前面的商品可能已在事务内写入了——处理器必须判断"给你的这条",而不是假设什么都没发生。
- 逐条拒绝 ≠ 批量失败:批量循环捕获某条
validate派发中reject!抛出的FailureSignal,跳过该条并记录 warning(item_index、code、message,从该条的 errors 对象派生);一条保存失败的行(如库存可用性校验不过)同样被当作该条的问题处理,不回滚整批。购物车最终只对真正生效的内容重算一次——这就是批量相对"循环调 AddItem"的全部性能收益:钱的计算是贵的那部分,一打商品大约只花一件商品的工作量。计划明确拒绝了字面upsert_all,因为它会绕过 ActiveRecord 校验、归一化与金额路径上的行项目价格捕捉。
跳过项经由 Spree::Carts::ItemWarning 写入购物车既有的warnings数组(缺货清扫已经在用同一通道、同一形状),客户端读一处词汇表即可,不需要新的顶层响应键。这是为 storefront"恢复我的旧购物车"流程设计的选择:一个已停产的变体不应阻塞其余商品重新加购。
订单侧孪生:整批失败
Orders::UpsertItems 只覆写三处:把order:规范关键字映射为cart:传给父类、recalculate?返回false(Admin 编辑把 items、fulfillments、coupons 跑成一条管线、在末尾统一重算一次)、partial_success?返回false——商家笔下被划掉或改价的一行静默不生效比请求失败更糟,所以订单侧整批失败。购物车侧则保留部分成功。这是 2026-08-13 Phase 2 期间拍板的双侧行为差异。
其他实现细节同样能对应计划中的 Note:
- 删除通过购物车自身的 line items 解析变体(resolve_variant)而非
store.variants:一个已被删除或下架的商品已离开该作用域,拒绝删除会让顾客困在一行删不掉的记录里;而新增仍走 store 作用域解析,别家租户的变体是 404 而非跨店加购。 - 6.0 中刻意没有整批否决:在
upsert_items.validate内拒绝永远是逐条的(想挡住一切的处理器就拒绝所有条目;批次级否决只有被要求时才会出现)。单条流程(AddItem、complete、products.*)不受影响:那里的拒绝仍是硬 422。
废弃壳与依赖键
Carts::SetQuantity和Carts::RemoveLineItem成为废弃壳。SetQuantity 的实现 值得细看:它内部改调Spree::Carts::UpsertItems(数量 0 即删除),并做了一件计划隐含的适配工作——把 workflow 的"批量契约"翻译回旧调用方的期望:workflow 在:validate处理器跳过该条时是成功的(成功 + warning),而旧 service 的调用方期待失败,所以它检查workflow.warnings.first并把拒绝翻译为failure(line_item, rejection.message)。Dependencies键(cart_set_item_quantity_service、cart_remove_line_item_service)按既定废弃桥约定保留可读、带警告,直至 6.1。
Products::* 工作流的接线
Products::Create/Update包裹既有保存路径:赋值属性(含嵌套 variants)、以脏记录可读的状态(workflow.product,可检视product.changes)运行run_hooks :validate,随后在事务内保存并触发生命周期钩子;Create 的 perform 清晰展示了顺序:build_product→run_hooks :validate(在 insert 之前,拒绝零回滚成本)→ 事务内save_product+apply_nested_attributes→after_create。Products::Destroy以相同方式包裹 paranoia 软删除,让宿主清理挂到after_destroy。
接线清单:
- Admin API v3 商品控制器的
create/update/destroy经Spree.product_create_workflow/product_update_workflow/product_destroy_workflow依赖键调用 workflow; - CSV 导入器的商品行处理调用同一组workflow——处理器因此对每个导入行触发,这是有意的(一个闸门),文档必须声明 validate 处理器要快、且不含每次调用的外部 I/O;
- Seeds 与样例数据同样经过 workflow,宿主 validate 处理器若拒绝样例数据会响亮地暴露而不是静默分叉。
Product/Variant上的模型校验原样保留——workflow 钩子是宿主策略,不是数据完整性规则的替代品。
Address 打磨与文档修正
针对地址的收尾工作(而非新机制):
- 文档化
Spree.validators.addresses注册表并补上移除 API。实现 中Set子类化Array(保持历史行为——宿主可能已经在 map/push 这个数组),在数组行为之上提供register/unregister作为文档化 API,与Spree.hooks.unregister对称;注册自己类的校验器时建议放在config.to_prepare而非 initializer,因为 Set 持有的是类,reload 后常量会过期。 - 在既有
require_company?谓词后补上require_companystore preference(此前该谓词硬编码返回false,没有旋钮)。 - 补齐"customizing validations"文档小节:additive decorators 受支持;放松核心规则 = 存在 preference 就用 preference,否则覆写门控谓词;
clear_validators!被显式劝阻。
对当前开发的约束
计划文档的 Constraints 一节直接转化为开发团队的硬规则,值得作为检查清单保留:
- 新代码改购物车商品行必须走
Carts::AddItem或Carts::UpsertItems——不得从 service 或控制器直接写 line item; - 新的商品写入路径(任何导入器、同步任务、API 面)调用
Products::*workflow,而不是直接product.save; - 钩子载体 workflow 的
render_service_error调用点不硬编码 HTTP 错误码——让拒绝的errors对象自己携带代码; - 客户端(SDK、dashboard、storefront 示例)必须把批量商品端点当作部分成功对待:2xx 响应要检查
warnings数组,永远不要假设请求的每个商品都写入了; - 新 validate 处理器应向
workflow.errors添加符号化错误并调用无参reject!;reject!(message)对新代码是遗留风格; - 不再新增全局校验开关——
disable_sku_validation模式继续放在Spree::Storepreferences 上; - 自定义字段的值校验(已延后的独立主题)将来必须落在
CustomFieldmodel 上、只门控新写入,而不是从控制器起步。
迁移路径与实现注记
四个阶段均已于 2026-08-13 完成(文档标注 Status: Implemented),且全程无 schema 变更:
- Phase 1 — errors 契约 + 启动检查:
Workflow#errors、无参reject!、reject!(message)→:base桥接、停止在钩子载体调用点硬编码错误码、Spree.hooks.validate!接入启动; - Phase 2 —
Carts::UpsertItems升级:workflow + 与AddItem共享的商品应用(税估、库存预留、重算行为不再漂移)、逐条validate的 skip-and-warn 语义、quantity-0 删除;create-with-items、批量 items、PATCH quantity、DELETE item 全部路由经过它;Store API 购物车响应带warnings数组 + SDK 类型;SetQuantity/RemoveLineItem变废弃壳;Orders::UpsertItems孪生随行;Carts::Complete测试套件须不修改通过; - Phase 3 —
Products::*workflow:Create/Update/Destroy、Admin API 接线、CSV 导入器 + seeds 路由、依赖键; - Phase 4 — Address 打磨 + 文档:注册表文档 + 移除 API、
require_companypreference、校验定制指南、checkout 文档替换。
实现期还留下两条值得所有 workflow 作者记住的注记:其一,workflow 编写陷阱——在#perform内参数名是局部变量,会遮蔽生成的 reader,step 中重新赋值@product对后面的success(product)不可见(Products::Create因此接收record:、暴露product,这一差异在 workflow.rb 的注释 中有专门说明);其二,订单侧整批失败与 storefront 侧部分成功的双侧语义差异,是 Phase 2 期间基于"商家被静默跳过的行比失败的请求更糟"这一判断敲定的。
小结
这份计划的价值在于一次克制的设计收敛:它没有发明通用校验注册表,而是把"哪里可以否决"这件事集中到 workflowvalidate钩子(carts.upsert_items.validate、products.*.validate),把"拒绝长什么样"集中到ActiveModel::Errors+reject!的统一 422 形状,把"模型形状规则"明确留在模型与 store preferences 一侧。对扩展开发者而言,实践路径非常具体:流程级规则写处理器类、向Spree.hooks注册具名键、错误用符号 code + message;模型形状规则用 additive decorator 或 preference;批量端点客户端一律检查warnings。相关深入阅读:6.0-service-workflows.md(钩子家族与分层教义)、decisions.md、workflows 定制文档 与 decorators 文档。
【免费下载链接】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),仅供参考