PAYATHON 2026

如何在 Rails 中为订单设置 shipping 和 billing address

支付老李

结论

这里不需要使用 STI。ShippingAddress 和 BillingAddress 并不是两种地址类型,而是同一个 Address 在订单中承担的两种角色。

可以按下面的方式处理:

  1. 在 orders 表中保存 billing_address_id 和 shipping_address_id。
  2. 在 Order 中定义两个指向 Address 的 belongs_to 关联。
  3. 使用 accepts_nested_attributes_for 接收嵌套参数。
  4. 在表单中用 fields_for 分别渲染账单地址和收货地址。

生成的字段名如下:

order[billing_address_attributes][zipcode]
order[shipping_address_attributes][zipcode]

Rails 会把这两组参数写入各自关联的地址对象。

模型定义

class Order < ApplicationRecord
  belongs_to :billing_address,
             class_name: "Address"

  belongs_to :shipping_address,
             class_name: "Address"

  accepts_nested_attributes_for :billing_address
  accepts_nested_attributes_for :shipping_address
end

class_name: "Address" 表示这两个关联使用的都是 Address 模型。Rails 会根据关联名称查找对应的外键:

billing_address_id
shipping_address_id

Address 不必拆成两个子类:

class Address < ApplicationRecord
  validates :zipcode, presence: true
end

原示例中的关联选项应该使用 class_name,不能写成 class:

belongs_to :billing_address, class_name: "Address"

数据库结构

orders 表需要两个指向 addresses 表的外键:

class AddAddressesToOrders < ActiveRecord::Migration[7.1]
  def change
    add_reference :orders,
                  :billing_address,
                  foreign_key: { to_table: :addresses }

    add_reference :orders,
                  :shipping_address,
                  foreign_key: { to_table: :addresses }
  end
end

迁移版本号要与项目使用的 Rails 版本一致。

核心结构如下:

orders.billing_address_id  -> addresses.id
orders.shipping_address_id -> addresses.id

这两个字段可以保存同一个地址 ID,也就是说,账单地址和收货地址可以指向同一条记录。

初始化关联对象

渲染新建订单表单前,需要先构建两个地址对象,否则 fields_for 可能找不到可渲染的对象:

class OrdersController < ApplicationController
  def new
    @order = Order.new
    @order.build_billing_address
    @order.build_shipping_address
  end

  def create
    @order = Order.new(order_params)

    if @order.save
      redirect_to @order
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def order_params
    params.require(:order).permit(
      :email,
      billing_address_attributes: address_attributes,
      shipping_address_attributes: address_attributes
    )
  end

  def address_attributes
    %i[
      id
      recipient_name
      street
      city
      province
      zipcode
      country
    ]
  end
end

如果需要通过嵌套表单更新已有地址,通常要允许提交 id。不允许提交时,Rails 可能会把编辑操作当成创建新地址。

使用 form_for 构建表单

仍在使用 form_for 的 Rails 项目可以这样写:

<%= form_for @order do |form| %>
  <%= form.email_field :email %>

  <fieldset>
    <legend>账单地址</legend>

    <%= form.fields_for :billing_address do |address_form| %>
      <%= address_form.label :recipient_name %>
      <%= address_form.text_field :recipient_name %>

      <%= address_form.label :street %>
      <%= address_form.text_field :street %>

      <%= address_form.label :city %>
      <%= address_form.text_field :city %>

      <%= address_form.label :zipcode %>
      <%= address_form.text_field :zipcode %>
    <% end %>
  </fieldset>

  <fieldset>
    <legend>收货地址</legend>

    <%= form.fields_for :shipping_address do |address_form| %>
      <%= address_form.label :recipient_name %>
      <%= address_form.text_field :recipient_name %>

      <%= address_form.label :street %>
      <%= address_form.text_field :street %>

      <%= address_form.label :city %>
      <%= address_form.text_field :city %>

      <%= address_form.label :zipcode %>
      <%= address_form.text_field :zipcode %>
    <% end %>
  </fieldset>

  <%= form.submit %>
<% end %>

fields_for 必须通过订单的表单构建器调用:

<%= form.fields_for :billing_address do |address_form| %>

不要单独对地址调用 form_for。单独构建地址表单时,Rails 只知道当前对象是 Address,所以会生成 address[zipcode] 这样的字段名。

使用 form_with

较新的 Rails 项目通常使用 form_with,关联部分的写法没有变化:

<%= form_with model: @order do |form| %>
  <h2>账单地址</h2>

  <%= form.fields_for :billing_address do |address_form| %>
    <%= address_form.text_field :recipient_name %>
    <%= address_form.text_field :street %>
    <%= address_form.text_field :city %>
    <%= address_form.text_field :zipcode %>
  <% end %>

  <h2>收货地址</h2>

  <%= form.fields_for :shipping_address do |address_form| %>
    <%= address_form.text_field :recipient_name %>
    <%= address_form.text_field :street %>
    <%= address_form.text_field :city %>
    <%= address_form.text_field :zipcode %>
  <% end %>

  <%= form.submit %>
<% end %>

处理“收货地址与账单地址相同”

如果用户勾选“与账单地址相同”,不要直接让前端提交两套完全相同的嵌套地址数据。这样做通常会创建两条内容相同、ID 不同的 addresses 记录。

可以在控制器或专门的表单对象中复制属性。例如,先接收一个虚拟字段:

class Order < ApplicationRecord
  attr_accessor :shipping_same_as_billing

  belongs_to :billing_address, class_name: "Address"
  belongs_to :shipping_address, class_name: "Address"

  accepts_nested_attributes_for :billing_address
  accepts_nested_attributes_for :shipping_address
end

保存前再按照具体的业务规则处理。首先要确定系统需要哪种语义:

  • 两个关联指向同一条地址记录;
  • 创建两条内容相同、彼此独立的地址记录;
  • 把地址内容直接作为快照保存在订单中。

订单系统通常更适合保存地址快照。用户以后修改通讯录中的地址时,历史订单的配送地址不应跟着变化。因此,即使账单地址和收货地址目前相同,也可以为订单保存两份独立快照。

注意事项

STI 用于表示真正存在继承关系,而且行为有所不同的模型类型。账单地址和收货地址通常只是用途不同,字段和行为基本一致,没有必要为此建立 STI 层次。

嵌套表单生成的参数也不是简单的:

billing_address[zipcode]

正确格式是:

order[billing_address_attributes][zipcode]

这是 accepts_nested_attributes_for 要求的参数结构。模型关联、fields_for 和 Strong Parameters 保持一致后,Rails 就能同时创建或更新两组地址。

备注:内容仅供参考。