PAYATHON 2026

Ecto belongs_to 关联导致 Refund 插入失败

支付小周

结论

问题出在 belongs_to 的定义方式,不只是 changeset。

在 belongs_to :transaction_id, Payments.Payment 中,:transaction_id 会被 Ecto 视为关联名称。Ecto 可能据此生成外键字段 transaction_id_id,并默认引用 Payment 的主键 id。这与实际需求不符:Refund 应通过字符串字段 transaction_id 关联 Payment.transaction_id。

需要做以下调整:

  1. 使用实体名称作为关联名称,例如 :payment。
  2. 用 foreign_key: :transaction_id 指定 Refund 表中的外键字段。
  3. 用 references: :transaction_id 指定 Payment 表中被引用的字段。
  4. 该字段是字符串,因此需要设置 type: :string。
  5. changeset 的默认参数应为 %{},不能使用 []。
  6. 将 ::transaction_id 修正为 :transaction_id。

正确的 Schema 配置

Payment

defmodule Payments.Payment do
  use Payments.Web, :model

  schema "payment" do
    field :payment_id, :string
    field :captured_amount, :decimal
    field :transaction_id, :string

    has_many :refunds,
      Payments.Refund,
      foreign_key: :transaction_id,
      references: :transaction_id

    timestamps()
  end

  def changeset(payment, params \\ %{}) do
    payment
    |> cast(params, [:payment_id, :transaction_id, :captured_amount])
    |> validate_required([:payment_id, :transaction_id, :captured_amount])
    |> unique_constraint(:transaction_id)
  end
end

原代码中的:

validate_required([:payment_id, ::transaction_id, :captured_amount])

应改为:

validate_required([:payment_id, :transaction_id, :captured_amount])

多出的冒号会产生错误的语法或 AST,无法表示普通字段名。

Refund

defmodule Payments.Refund do
  use Payments.Web, :model

  @primary_key {:refund_id, :string, autogenerate: false}

  schema "refund" do
    field :state, :string
    field :pay_id, :string
    field :amount, :decimal
    field :create_time, Ecto.DateTime

    belongs_to :payment, Payments.Payment,
      foreign_key: :transaction_id,
      references: :transaction_id,
      type: :string

    timestamps()
  end

  def changeset(refund, params \\ %{}) do
    refund
    |> cast(params, [
      :refund_id,
      :state,
      :transaction_id,
      :pay_id,
      :amount,
      :create_time
    ])
    |> validate_required([
      :refund_id,
      :transaction_id,
      :pay_id,
      :amount
    ])
    |> foreign_key_constraint(:transaction_id)
  end
end

这里不需要另外定义:

field :transaction_id, :string

belongs_to 会按照 foreign_key: :transaction_id 自动定义该字段。再次定义通常会造成字段冲突。

为什么原来的 belongs_to 有问题

这段代码:

belongs_to :transaction_id, Payments.Payment

并不是让 Ecto 使用 transaction_id 作为外键。第一个参数表示关联名称,Ecto 会根据这个名称推导外键。

使用默认配置时,它大致相当于:

belongs_to :transaction_id, Payments.Payment,
  foreign_key: :transaction_id_id,
  references: :id

由此会产生三个问题:

  • Refund 中的实际字段是 transaction_id,Ecto 却可能查找 transaction_id_id。
  • transaction_id 是字符串,而默认关联键通常按整数主键处理。
  • 需要引用的是 Payment.transaction_id,默认引用的却是 Payment.id。

关联名称和外键字段应分别声明:

belongs_to :payment, Payments.Payment,
  foreign_key: :transaction_id,
  references: :transaction_id,
  type: :string

这样既可以通过:

refund.payment

访问关联,也能让 refund.transaction_id 保存实际的外键值。

数据库迁移也必须一致

只修改 Schema 还不够,数据库外键约束也要引用正确的列。

Payment 表的迁移示例:

create table(:payment) do
  add :payment_id, :string, null: false
  add :captured_amount, :decimal, null: false
  add :transaction_id, :string, null: false

  timestamps()
end

create unique_index(:payment, [:transaction_id])

Refund 表可以这样创建:

create table(:refund, primary_key: false) do
  add :refund_id, :string, primary_key: true
  add :state, :string
  add :pay_id, :string, null: false
  add :amount, :decimal, null: false
  add :create_time, :utc_datetime

  add :transaction_id,
      references(:payment,
        column: :transaction_id,
        type: :string,
        on_delete: :restrict
      ),
      null: false

  timestamps()
end

create index(:refund, [:transaction_id])

被外键引用的 Payment.transaction_id 应有唯一约束。否则,同一个 transaction_id 可能对应多条 Payment 记录,关联结果将无法确定。部分数据库也会直接拒绝创建指向非唯一字段的外键。

如果表已经存在,应使用 alter table 调整字段和约束,不要再次执行 create table。

插入 Refund

参数应使用 map,不能使用关键字列表或普通列表:

params = %{
  "refund_id" => "refund_001",
  "state" => "created",
  "transaction_id" => "txn_123",
  "pay_id" => "payment_001",
  "amount" => Decimal.new("25.50"),
  "create_time" => Ecto.DateTime.utc()
}

%Payments.Refund{}
|> Payments.Refund.changeset(params)
|> Payments.Repo.insert()

也可以使用 atom key:

params = %{
  refund_id: "refund_001",
  state: "created",
  transaction_id: "txn_123",
  pay_id: "payment_001",
  amount: Decimal.new("25.50")
}

插入 Refund 前,必须先存在 transaction_id 对应的 Payment。例如:

%Payments.Payment{}
|> Payments.Payment.changeset(%{
  payment_id: "payment_001",
  transaction_id: "txn_123",
  captured_amount: Decimal.new("100.00")
})
|> Payments.Repo.insert()

随后才能插入引用 "txn_123" 的 Refund。

关于 no function clause matching

原代码中较容易引发这个错误的是:

def changeset(struct, params \\ [])

cast/3 需要 map 形式的参数。调用 changeset(refund) 时,默认值 [] 可能无法匹配 cast/3 所需的函数分支。默认参数应改成:

def changeset(struct, params \\ %{})

如果调用方传入的是关键字列表,需要先转换为 map:

params
|> Enum.into(%{})
|> then(&Payments.Refund.changeset(%Payments.Refund{}, &1))

更合适的做法是从调用源头直接传入 map。

版本注意事项

示例中的 Ecto.DateTime 来自较早版本的 Ecto。较新的 Ecto 项目通常使用:

field :create_time, :utc_datetime

或者:

field :create_time, :naive_datetime

具体类型取决于数据库字段是否需要时区语义。如果项目仍在使用旧版 Ecto,可以暂时保留 Ecto.DateTime,但不要混用新旧版本的日期时间类型。

最后要确保以下四处完全一致:

  • Payment.transaction_id 的类型是 :string。
  • Refund.transaction_id 的类型是 :string。
  • Schema 使用 references: :transaction_id。
  • 数据库外键通过 column: :transaction_id 引用同一个字段。

备注:内容仅供参考。