PAYATHON 2026

Django Model 如何引用多个 Foreign Key 的子属性

支付老李

结论

ForeignKey 的目标必须是 Model 类或模型名称,不能是 Customer.payments.payment_mode 这样的反向关系属性。

Customer.payments 是反向关系管理器,表示与该客户关联的多个 Payment 对象,并不是某个具体的 Payment 实例,因此不能直接访问 payment_mode。

可以让 Invoice 直接关联选中的 Payment:

class Invoice(models.Model):
    customer = models.ForeignKey(
        Customer,
        on_delete=models.RESTRICT,
        related_name='invoices',
    )
    payment = models.ForeignKey(
        Payment,
        on_delete=models.RESTRICT,
        related_name='invoices',
    )

付款方式可通过下面的写法取得:

invoice.payment.payment_mode

为什么会出现这个错误

在下面的定义中:

class Payment(models.Model):
    customer = models.ForeignKey(
        Customer,
        on_delete=models.RESTRICT,
        related_name='payments',
    )

related_name='payments' 会在 Customer 上创建反向关系。对于某个具体客户,可以这样查询其付款记录:

customer.payments.all()

但如果从模型类访问:

Customer.payments

得到的是 ReverseManyToOneDescriptor。它描述的是“一名客户对应多个 Payment”的关系,并不是一条具体的付款记录。因此,下面的写法无效:

Customer.payments.payment_mode

原来的两个外键都使用了 related_name='payments',还会造成反向关系名称混乱。发票关系应使用单独的名称,例如 invoices。

推荐的模型设计

如果一张发票需要同时保存客户和所选的付款记录,可以这样定义:

from django.core.exceptions import ValidationError
from django.db import models


class Customer(models.Model):
    # some customer details
    pass


class Payment(models.Model):
    customer = models.ForeignKey(
        Customer,
        on_delete=models.RESTRICT,
        related_name='payments',
    )
    payment_mode = models.CharField(
        max_length=255,
        blank=True,
        null=True,
    )

    def __str__(self):
        return self.payment_mode or f'Payment #{self.pk}'


class Invoice(models.Model):
    customer = models.ForeignKey(
        Customer,
        on_delete=models.RESTRICT,
        related_name='invoices',
    )
    payment = models.ForeignKey(
        Payment,
        on_delete=models.RESTRICT,
        related_name='invoices',
    )

    def clean(self):
        super().clean()

        if (
            self.customer_id
            and self.payment_id
            and self.payment.customer_id != self.customer_id
        ):
            raise ValidationError({
                'payment': '所选付款方式不属于当前客户。',
            })

创建发票时:

customer = Customer.objects.get(pk=1)
payment = customer.payments.get(pk=3)

invoice = Invoice(
    customer=customer,
    payment=payment,
)
invoice.full_clean()
invoice.save()

读取付款方式:

invoice.payment.payment_mode

跨关系查询可以这样写:

Invoice.objects.filter(
    customer=customer,
    payment__payment_mode='cash',
)

在表单中只显示当前客户的付款方式

模型中的 ForeignKey 不会根据另一个字段自动过滤选项。使用 ModelForm 时,可以按当前客户限制 payment 字段的 queryset:

from django import forms


class InvoiceForm(forms.ModelForm):
    class Meta:
        model = Invoice
        fields = ['customer', 'payment']

    def __init__(self, *args, customer=None, **kwargs):
        super().__init__(*args, **kwargs)

        if customer is not None:
            self.fields['customer'].initial = customer
            self.fields['payment'].queryset = customer.payments.all()
        elif self.instance.pk and self.instance.customer_id:
            self.fields['payment'].queryset = self.instance.customer.payments.all()
        else:
            self.fields['payment'].queryset = Payment.objects.none()

调用表单:

form = InvoiceForm(customer=customer)

服务端仍要保留 clean() 中的校验。过滤表单选项只能改善界面体验,无法阻止用户绕过表单提交不匹配的数据。

可以进一步简化关系

如果发票关联的 Payment 一定能唯一确定客户,那么 Invoice.customer 就属于重复数据,可以只保存 payment:

class Invoice(models.Model):
    payment = models.ForeignKey(
        Payment,
        on_delete=models.RESTRICT,
        related_name='invoices',
    )

    @property
    def customer(self):
        return self.payment.customer

此时可以直接使用:

invoice.customer
invoice.payment.payment_mode

这种结构不会出现“发票客户与付款记录所属客户不一致”的情况。查询某个客户的发票时,可以写成:

Invoice.objects.filter(payment__customer=customer)

是否保留独立的 customer 字段,要看具体业务需求。如果客户只是从 Payment 推导出来,通常不需要重复保存。如果发票必须单独记录客户,则可以保留两个外键,但要校验两者是否一致。

如果发票还需要保留开票时的付款方式文本,并且后续修改 Payment.payment_mode 不应影响历史发票,可以增加 payment_mode_snapshot 这样的快照字段,在创建发票时复制当时的值。

备注:内容仅供参考。