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可以防止误删仍被账单引用的账期。用户是否允许级联删除,则要根据审计和数据保留要求决定。- “为每个月生成所有用户账单”通常应交给定时任务或管理命令执行,不应等到用户打开页面时再临时生成。
- 账单属于财务历史数据。生产系统通常不应在用户注销时直接删除账单,而应考虑匿名化用户信息并保留账单记录。此时需要相应调整用户外键的删除策略和相关字段设计。
备注:内容仅供参考。
