PAYATHON 2026

Django 如何存储用户每月账单并单独更新支付状态

支付小周

结论

账单不能简单地建模为用户的一对多数据,更准确的关系是“用户 × 账单月份”。可以这样设计:

  • BillingPeriod 表示一个账期,例如 2020 年 1 月;
  • UserBill 表示某位用户在该账期内的账单;
  • 金额、支付状态和支付时间等字段保存在 UserBill 中;
  • 使用数据库唯一约束,确保同一用户在同一账期只有一条账单。

这样,每位用户每个月都有独立的账单记录。修改某个人的支付状态时,不会影响其他用户或其他月份。

Model 设计

下面的示例使用 Django 当前项目配置的用户模型,没有直接引用 User:

from django.conf import settings
from django.db import models


class BillingPeriod(models.Model):
    year = models.PositiveSmallIntegerField()
    month = models.PositiveSmallIntegerField()

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["year", "month"],
                name="unique_billing_period",
            ),
            models.CheckConstraint(
                condition=models.Q(month__gte=1, month__lte=12),
                name="valid_billing_month",
            ),
        ]
        ordering = ["-year", "-month"]

    def __str__(self):
        return f"{self.year}-{self.month:02d}"


class UserBill(models.Model):
    class PaymentStatus(models.TextChoices):
        UNPAID = "unpaid", "Unpaid"
        PAID = "paid", "Paid"
        OVERDUE = "overdue", "Overdue"

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="bills",
    )
    period = models.ForeignKey(
        BillingPeriod,
        on_delete=models.PROTECT,
        related_name="user_bills",
    )
    amount = models.DecimalField(
        max_digits=10,
        decimal_places=2,
        default=0,
    )
    payment_status = models.CharField(
        max_length=20,
        choices=PaymentStatus.choices,
        default=PaymentStatus.UNPAID,
    )
    paid_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["user", "period"],
                name="unique_user_bill_per_period",
            ),
        ]
        indexes = [
            models.Index(fields=["period", "payment_status"]),
        ]
        ordering = ["period", "user"]

    def __str__(self):
        return f"{self.user} - {self.period} - {self.payment_status}"

UserBill 是一张包含业务字段的中间表。它连接用户和账期,并保存这个组合对应的金额与支付状态。

支付状态不适合放在 User 或 BillingPeriod 上:

  • 如果放在 User 上,只能记录用户当前的一个状态,无法保留每个月的记录;
  • 如果放在 BillingPeriod 上,整个账期只能共用一个状态,无法按用户分别更新。

为某个月生成所有用户的账单

创建账期后,可以批量为当前需要计费的用户生成账单:

from django.contrib.auth import get_user_model
from django.db import transaction

from billing.models import BillingPeriod, UserBill


User = get_user_model()


@transaction.atomic
def generate_monthly_bills(year, month):
    period, _ = BillingPeriod.objects.get_or_create(
        year=year,
        month=month,
    )

    existing_user_ids = set(
        UserBill.objects.filter(period=period)
        .values_list("user_id", flat=True)
    )

    users = User.objects.filter(is_active=True).exclude(
        id__in=existing_user_ids
    )

    bills = [
        UserBill(
            user=user,
            period=period,
            amount="0.00",
            payment_status=UserBill.PaymentStatus.UNPAID,
        )
        for user in users
    ]

    UserBill.objects.bulk_create(bills)

    return period

例如,生成 2020 年 1 月的账单:

period = generate_monthly_bills(2020, 1)

这段代码只为还没有账单的用户创建记录。数据库中的唯一约束提供最后一层保护,防止同一用户在同一账期出现重复账单。

如果每位用户的账单金额不同,应在创建 UserBill 时根据套餐、用量或合同计算金额,不能统一使用示例中的 "0.00"。

查询某个月的账单列表

january_bills = (
    UserBill.objects
    .filter(period__year=2020, period__month=1)
    .select_related("user", "period")
)

在模板中可以直接遍历这些账单:

<h2>{{ period }}</h2>

<ul>
  {% for bill in bills %}
    <li>
      {{ bill.user.username }}:
      {{ bill.get_payment_status_display }}
    </li>
  {% empty %}
    <li>该账期暂无账单。</li>
  {% endfor %}
</ul>

查询未支付账单也很简单:

unpaid_bills = UserBill.objects.filter(
    period__year=2020,
    period__month=2,
    payment_status=UserBill.PaymentStatus.UNPAID,
)

单独更新某位用户的支付状态

更新支付状态时,需要同时限定用户和账期,否则可能误改该用户其他月份的账单:

from django.shortcuts import get_object_or_404
from django.utils import timezone

from billing.models import UserBill


bill = get_object_or_404(
    UserBill,
    user=request.user,
    period__year=2020,
    period__month=2,
)

bill.payment_status = UserBill.PaymentStatus.PAID
bill.paid_at = timezone.now()
bill.save(update_fields=["payment_status", "paid_at", "updated_at"])

如果只需直接更新数据库,也可以这样写:

UserBill.objects.filter(
    user_id=user_id,
    period__year=2020,
    period__month=2,
).update(
    payment_status=UserBill.PaymentStatus.PAID,
    paid_at=timezone.now(),
)

update() 不会调用模型的 save() 方法,也不会触发依赖 save() 的自定义逻辑或相关信号。如果模型包含这类处理,应先获取对象,再调用 save()。

通过用户反向查询账单

由于 ForeignKey 设置了 related_name="bills",可以直接从用户对象查询账单:

user_bills = user.bills.select_related("period").all()

查询某位用户已经支付的账单:

paid_bills = user.bills.filter(
    payment_status=UserBill.PaymentStatus.PAID,
)

也可以从账期反向查询所有用户的账单:

period = BillingPeriod.objects.get(year=2020, month=1)

bills = period.user_bills.select_related("user").all()

一个表也能实现,但扩展性较弱

如果账期没有结算日期、截止日期或账期状态等属性,也可以只保留一张账单表:

class UserBill(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="bills",
    )
    billing_month = models.DateField()
    payment_status = models.CharField(max_length=20)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["user", "billing_month"],
                name="unique_user_billing_month",
            ),
        ]

此时需要约定 billing_month 始终保存当月第一天,例如 2020-01-01。这种设计比较简单,但必须通过表单或模型校验,确保保存的日期确实是每月第一天。

如果以后需要给账期添加统一的截止日期、关闭状态或备注,单独使用 BillingPeriod 会更清楚,也不用在每位用户的账单中重复保存相同信息。

注意事项

  • 金额应使用 DecimalField,不要使用 FloatField,否则可能产生浮点精度问题。
  • paid_at 应允许为空。未支付时保存为 NULL,支付成功后再写入时间。
  • 如果支付状态由支付平台回调更新,操作应具备幂等性,避免重复通知造成重复记账。
  • on_delete=models.PROTECT 可以防止误删仍被账单引用的账期。用户是否允许级联删除,则要根据审计和数据保留要求决定。
  • “为每个月生成所有用户账单”通常应交给定时任务或管理命令执行,不应等到用户打开页面时再临时生成。
  • 账单属于财务历史数据。生产系统通常不应在用户注销时直接删除账单,而应考虑匿名化用户信息并保留账单记录。此时需要相应调整用户外键的删除策略和相关字段设计。

备注:内容仅供参考。