PAYATHON 2026

Spree 2.0 结账时出现 ActiveRecord::RecordNotFound

支付阿杰

结论

这个异常说明:结账表单提交了 ID 为 45 的 shipment,但当前订单 6 的 shipments 关联里已经找不到这条记录。Rails 处理 shipments_attributes 嵌套参数时,只会在当前订单的配送记录中查找该 ID,因此抛出了 ActiveRecord::RecordNotFound。

这不一定意味着数据库里完全没有 ID 为 45 的记录。更常见的是以下两种情况:

  • Spree::Shipment.find(45) 能查到记录,但它不属于订单 6;
  • 重新计算配送方式后,Shipment 45 已被删除或替换,而浏览器仍在提交旧页面里的 ID。

不要修改嵌套属性逻辑、跳过关联校验,也不要手动篡改 order_id 来绕过这个异常。应该先查清 Shipment 为什么会在页面显示后被重建或删除。

异常是如何产生的

配送步骤通常会提交类似下面的参数:

{
  order: {
    shipments_attributes: {
      "0" => {
        id: "45",
        selected_shipping_rate_id: "12"
      }
    }
  }
}

订单模型收到 shipments_attributes 后,Active Record 执行的关联查询接近于:

order.shipments.find(45)

它和下面的直接查询并不相同:

Spree::Shipment.find(45)

即使 ID 45 还在数据库里,只要它的 order_id 不是 6,第一种查询就会失败。如果这条 Shipment 已经被删除,也会出现同样的异常。

结账期间,Spree 可能根据收货地址、库存位置、商品可用性和配送方式重新生成 Shipment 与 ShippingRate。因此,Shipment ID 不一定会在整个结账过程中保持不变。

先检查提交参数和数据库状态

先查看 Rails 日志里该请求的 Parameters,确认浏览器实际提交了哪个 Shipment ID。重点检查:

order
shipments_attributes
id
selected_shipping_rate_id
state

然后进入控制台:

bundle exec rails console

查询订单当前关联的配送记录:

order = Spree::Order.find(6)

order.shipments.map do |shipment|
  [shipment.id, shipment.order_id, shipment.state]
end

再单独查询 ID 45:

shipment = Spree::Shipment.where(:id => 45).first

if shipment
  [shipment.id, shipment.order_id, shipment.state]
else
  "Shipment 45 does not exist"
end

还可以检查订单当前的配送费率:

order.shipments.each do |shipment|
  puts "Shipment: #{shipment.id}"

  shipment.shipping_rates.each do |rate|
    puts [
      rate.id,
      rate.shipping_method && rate.shipping_method.name,
      rate.selected
    ].inspect
  end
end

查询结果通常对应以下几种情况:

查询结果含义
Shipment 45 不存在页面或会话保留了已删除记录的 ID
Shipment 45 属于其他订单提交参数、页面缓存或自定义代码混用了不同订单的数据
订单 6 有其他 Shipment ID结账过程中重新生成了 Shipment
订单 6 当前仍关联 ID 45需要检查请求时序、事务、默认作用域或扩展代码

常见原因

1. 使用了过期的结账页面

这是最常见的情况。例如:

  1. 用户打开 /checkout/delivery,页面里包含 Shipment 45;
  2. 另一个请求重新计算并替换了 Shipment;
  3. 用户回到旧页面点击 “Save & Continue”;
  4. 浏览器仍然提交 id=45。

多标签页、浏览器后退、刷新旧页面或重复提交,都可能造成这种情况。

可以先关闭其他结账页面,清除当前购物车会话,重新把商品加入购物车,再完整走一遍结账流程。在开发环境中,也可以用无痕窗口测试,判断问题是否与旧 Cookie 或会话有关。

2. Shipment 在请求之间被重新生成

修改以下数据后,Spree 可能重新计算配送内容:

  • 收货地址;
  • 商品数量;
  • 库存或 Stock Location;
  • Shipping Method;
  • Calculator 配置;
  • 商品是否可配送;
  • 自定义拆单或配送逻辑。

检查从进入配送页到提交表单这段时间内的 SQL 日志,特别留意:

DELETE FROM "spree_shipments"
INSERT INTO "spree_shipments"
UPDATE "spree_shipments"

如果打开配送页时使用的是 ID 45,但提交前数据库里已经换成其他 ID,就需要继续查找触发重建的代码或请求。

3. 自定义代码或扩展重复更新订单

检查项目中的装饰器、回调和扩展代码:

Spree::Order.class_eval
Spree::Shipment.class_eval
before_save
after_save
after_update
update!
update_attributes
create_proposed_shipments

重点检查在读取配送页、计算运费或保存地址时修改订单的代码。GET 请求不应该改变配送记录,否则当前页面中的表单可能刚渲染出来就已经失效。

还要确认项目是否覆盖了:

Spree::CheckoutController
Spree::Order
Spree::Shipment

如果只安装官方基础组件时一切正常,而启用某个扩展后开始报错,应优先检查该扩展是否兼容 Spree 2.0 和 Rails 3.2。

4. 页面片段缓存了旧的 Shipment ID

如果配送表单或局部模板使用了 fragment cache,缓存键必须随订单和 Shipment 的变化而变化。否则,新订单可能收到包含旧 ID 的 HTML。

在开发环境中,可以暂时关闭缓存并清理缓存数据,再重新测试。但不要把”清缓存后恢复”当成最终修复。如果问题还能复现,就需要修正缓存键,或者取消对动态结账表单的缓存。

5. 前端代码提交了错误的 ID

检查配送步骤生成的 HTML,确认隐藏字段确实属于当前订单:

<input
  type="hidden"
  name="order[shipments_attributes][0][id]"
  value="45">

如果页面中的 Shipment ID 与 order.shipments.pluck(:id) 不一致,说明问题在渲染阶段就已经出现了,原因通常是自定义模板、错误缓存或手动拼装参数。

如果页面最初显示的 ID 正确,但点击按钮后请求参数发生变化,就继续检查 JavaScript 是否复制了旧表单、产生了重复索引,或者修改了隐藏字段。

建议的解决顺序

第一步:用全新会话复现

关闭旧标签页,用无痕窗口重新创建订单并完成结账。

如果新订单可以正常结账,原订单可能因旧会话、重复提交或开发期间的配置修改而处于不一致状态。测试阶段可以放弃这个购物车并新建订单。生产环境中不要直接删除订单数据,应保留记录并继续查明触发条件。

第二步:确认 ID 发生变化的时间

分别在配送页渲染前后及提交请求时记录 Shipment ID:

Rails.logger.info(
  "Order #{order.id} shipment IDs: #{order.shipment_ids.inspect}"
)

同时记录请求提交的 ID:

submitted_ids =
  Array(params.dig(:order, :shipments_attributes)).map do |_index, attributes|
    attributes[:id] || attributes["id"]
  end

Rails.logger.info("Submitted shipment IDs: #{submitted_ids.inspect}")

参数结构需要根据实际日志调整。在 Rails 3.2 项目中,参数可能是普通 Hash,具体形式也可能受 Spree 控制器的处理方式影响。这些诊断代码只应临时使用,而且不要把地址、电话等敏感信息写入日志。

第三步:逐个停用自定义扩展

如果问题可以稳定复现,可以暂时停用与以下功能有关的扩展,再逐个恢复:

  • 库存和仓库;
  • 配送方式及运费计算;
  • checkout 流程;
  • Order 或 Shipment 回调;
  • 页面缓存和前端表单。

通过这种方式,可以判断问题来自 Spree 基础流程、版本组合,还是项目的定制代码。

第四步:重建开发数据库进行对照测试

如果这是一个没有业务数据的本地示例环境,可以先备份,再重新创建数据库、执行迁移并加载 sample data。具体命令取决于项目当时采用的 Spree 安装方式,无法仅凭这个异常确定唯一命令。

重建数据库是为了排除开发期间反复迁移、切换 Spree 提交版本或修改配送配置造成的数据不一致。不要把它作为生产环境的修复方案。

第五步:固定依赖版本

错误路径显示 Spree 来自 Bundler 的 Git checkout:

bundler/gems/spree-562f390cbacf

这说明项目可能使用了某个 Git 提交,不一定采用正式发布且完全一致的 2.0 gem 组合。检查 Gemfile.lock,确认所有 Spree 组件来自彼此兼容的同一版本或同一提交系列,例如:

spree_core
spree_api
spree_backend
spree_frontend
spree_sample

不要混用一部分 stable-2-0 分支代码和另一部分不同版本的发布 gem。修改依赖后,需要重新安装依赖、执行对应迁移,并在干净的数据库中验证。

不建议的处理方式

不要直接删除表单里的 Shipment ID。嵌套更新需要这个 ID 来判断应该更新哪条记录。移除后,Rails 可能把参数理解为创建新的 Shipment,导致重复或不完整的数据。

也不要放宽关联查询,把:

order.shipments.find(params[:id])

改成:

Spree::Shipment.find(params[:id])

这样会破坏订单边界。严重时,一个订单可能修改另一个订单的配送记录。

同样,不要捕获 ActiveRecord::RecordNotFound 后直接继续结账。这个异常表明页面状态和数据库状态已经不一致。忽略它可能留下错误的库存、运费或配送关系。

最终判断标准

修复后应确认:

  • 配送页隐藏字段中的 Shipment ID 属于当前订单;
  • 从页面渲染到表单提交期间,Shipment 不会被无关请求删除或替换;
  • 多标签页或重复提交不会引发未处理异常;
  • 修改地址或库存导致配送方案变化时,页面会重新加载最新数据;
  • 所有 Spree 组件、扩展和数据库迁移都与同一版本线兼容。

从堆栈本身只能确认”提交的 Shipment 不属于当前订单关联”,无法据此认定唯一根因。定位时,最快的方法是同时对比页面隐藏字段中的 ID、提交参数、order.shipment_ids,以及对应时间段的 SQL 日志。

备注:内容仅供参考。