PAYATHON 2026

Django 根据 webhook 参数更新扩展用户 Profile 字段

支付老李

结论

先根据 clientAccnum 找到对应的 User,再通过一对一关系获取该用户的 Profile,然后更新模型中实际定义的 account_level 字段。

Profile.account_paid.update(True) 不能完成更新,原因有两个:

  • 当前 Profile 模型中没有 account_paid 字段,实际字段名是 account_level。
  • update() 属于 QuerySet,不能直接对模型字段调用。

如果 clientAccnum 保存的是 Django 用户主键,可以直接更新:

Profile.objects.filter(user_id=client_accnum).update(
    account_level=True
)

在 webhook 中,还要处理用户或 Profile 不存在、通知重复以及事件类型无关等情况。

基本实现

下面的示例基于这些前提:

  • clientAccnum 的值是 Django User.id;
  • RenewalSuccess 表示付费状态;
  • 只有明确的付款成功或失效事件才会修改账户状态。
from django.contrib.auth.models import User
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.utils import timezone
from django.views.decorators.http import require_POST

from .models import Profile, Webhook


@require_POST
def webhook(request):
    client_accnum = request.POST.get("clientAccnum")
    event_type = request.POST.get("eventType")

    if not client_accnum or not event_type:
        return HttpResponseBadRequest("Missing required parameters")

    try:
        user_id = int(client_accnum)
    except (TypeError, ValueError):
        return HttpResponseBadRequest("Invalid clientAccnum")

    try:
        user = User.objects.get(pk=user_id)
    except User.DoesNotExist:
        return HttpResponseBadRequest("Unknown clientAccnum")

    with transaction.atomic():
        Webhook.objects.create(
            user=client_accnum,
            clientSubacc=request.POST.get("clientSubacc"),
            eventType=event_type,
            eventGroupType=request.POST.get("eventGroupType"),
            subscriptionId=request.POST.get("subscriptionId"),
            timestamp=request.POST.get("timestamp"),
            timestamplocal=timezone.now(),
        )

        profile, _ = Profile.objects.get_or_create(user=user)

        if event_type == "RenewalSuccess":
            profile.account_level = True
            profile.save(update_fields=["account_level"])

        elif event_type in {"SubscriptionCancelled", "SubscriptionExpired"}:
            profile.account_level = False
            profile.save(update_fields=["account_level"])

    return HttpResponse(status=200)

使用 get_or_create() 后,即使用户还没有 Profile,也不会直接抛出 Profile.DoesNotExist。如果项目已经通过信号保证每个用户都有 Profile,也可以这样写:

profile = user.profile
profile.account_level = event_type == "RenewalSuccess"
profile.save(update_fields=["account_level"])

使用 QuerySet.update() 简化更新

如果能确定 Profile 已经存在,而且不需要触发模型的 save() 方法或相关信号,可以直接更新数据库:

updated_count = Profile.objects.filter(
    user_id=user_id
).update(
    account_level=True
)

updated_count 表示受影响的记录数。由于 Profile.user 是 OneToOneField,正常情况下只会返回 0 或 1:

if updated_count == 0:
    return HttpResponseBadRequest("Profile not found")

也可以先根据事件计算账户状态:

is_paid = event_type == "RenewalSuccess"

Profile.objects.filter(user_id=user_id).update(
    account_level=is_paid
)

这样写会把所有非 RenewalSuccess 事件都当作未付款。计费系统还可能发送测试、退款处理中或订阅变更等事件,所以更稳妥的方式是只处理明确支持的事件:

event_status = {
    "RenewalSuccess": True,
    "SubscriptionCancelled": False,
    "SubscriptionExpired": False,
}

if event_type in event_status:
    Profile.objects.filter(user_id=user_id).update(
        account_level=event_status[event_type]
    )

如果 clientAccnum 不是 Django 用户 ID

不能根据字段名称直接认定 clientAccnum 就是 User.id。如果它是计费平台生成的客户编号,应在本地保存一份明确的映射,并为该字段设置唯一约束:

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    billing_account_number = models.CharField(
        max_length=100,
        unique=True,
        null=True,
        blank=True,
    )
    bio = models.TextField(max_length=500, blank=True)
    birth_date = models.DateField(null=True, blank=True)
    account_level = models.BooleanField(default=False)

收到 webhook 后,通过外部编号查询 Profile:

try:
    profile = Profile.objects.get(
        billing_account_number=client_accnum
    )
except Profile.DoesNotExist:
    return HttpResponseBadRequest("Unknown clientAccnum")

profile.account_level = event_type == "RenewalSuccess"
profile.save(update_fields=["account_level"])

这种做法通常比直接把 Django 主键交给第三方更清楚,也能避免外部账户编号与本地用户编号的含义不一致。

统一字段名称

问题描述使用了 account_paid,模型中实际定义的是:

account_level = models.BooleanField(default=False)

代码必须使用模型中真实存在的字段名。如果字段的含义是“是否已经付款”,也可以把模型字段改成:

account_paid = models.BooleanField(default=False)

修改模型后,需要生成并执行数据库迁移:

python manage.py makemigrations
python manage.py migrate

完成迁移后,更新代码才可以写成:

Profile.objects.filter(user_id=user_id).update(
    account_paid=True
)

Webhook 处理注意事项

正式接入时,可以从 request.POST 读取表单数据。如果计费平台发送的是 JSON 请求体,则需要解析 request.body。具体处理方式取决于请求的 Content-Type:

import json

payload = json.loads(request.body)
client_accnum = payload.get("clientAccnum")

还需要处理以下问题:

  • 验证计费平台提供的签名或认证信息,不能只信任请求参数。
  • 使用事件 ID 或其他唯一标识,防止重复处理同一个 webhook。
  • 收到未知事件类型时,不要直接把付费状态改为 False。
  • 如果付款记录和账户状态必须同时写入成功,应使用 transaction.atomic() 保持一致性。
  • 处理成功后及时返回 2xx,因为失败状态码可能触发计费平台重试。
  • 如果事件可能乱序到达,应比较事件时间或订阅状态,避免旧通知覆盖新状态。

备注:内容仅供参考。