ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django Admin获取当前登录用户的六种方案与审计实践

Django Admin获取当前登录用户的六种方案与审计实践

前段时间在某个云管理平台的后台维护项目里,接了一个让我印象很深的活儿:要给Django Admin加一套“谁在什么时候改了什么”的操作审计功能。需求本身不复杂,但真正动手的时候才发现,最卡人的不是记录变更,而是——在 Admin 环境里,到底怎么干净利落地拿到“当前登录用户”。

这个需求听起来太基础了,基础到很多人觉得看一眼文档就会。但实际一查就会发现,Django Admin 后台获取当前用户这件事,水比想象中深得多。request.user不是处处可用,Model.save()里拿不到请求上下文,信号里更是一个头两个大。新手容易懵,老手也经常因为方案选得不合适,写出一堆又绕又脆弱的代码。

这篇文章我不打算讲太多“标准教科书”内容,就把我在真实项目里用过的、验证过的、踩过坑的方法完整拆开来讲。包括六种主流获取方式、底层原理、适用场景,以及一套可以直接落地的“操作审计 + 数据权限”综合方案。不管你是刚接触 Django Admin,还是已经被这类需求折磨过几轮,这篇应该都能帮上忙。

1. 先搞清楚:为什么“在Admin里拿当前用户”会是个问题

1.1 核心使用场景盘点

Django Admin 获取当前用户,绝大部分需求跑不出下面这几个场景。

第一类是自动填充审计字段。业务表里常见created_by、updated_by这类字段,谁创建的、谁最后改的,做数据追踪和问题追溯都用得上。Admin 后台里操作者就是当前登录的管理员,需要把这个用户对象自动写到字段里,而不是让操作人每次手动选择。我在那个云平台项目里做的就是这件事,后台有几十张表,不可能每张表的表单都让管理员自己选“我是谁”,必须自动填。

第二类是数据权限过滤。比如工单表,普通管理员只能看到自己创建的工单,超级管理员可以看到全部;再比如设备信息表,某些区域管理员只能维护自己区域内的设备。这种“按人过滤数据”的需求,需要在 Admin 的get_queryset里根据当前用户动态过滤。

第三类是操作日志和变更记录。除了字段里记录操作人,很多时候还要记录操作历史——某台服务器的配置是谁在什么时间改的,改之前是什么值,改之后变成了什么值。这个需要同时拿到当前用户和请求上下文。

第四类是动态默认值和动态表单行为。比如新增工单时,工单的“所属负责人”默认就是当前登录管理员;比如某些字段根据当前用户的角色决定是否只读、是否必填。这类需求在get_form、formfield_for_foreignkey这些方法里尤其常见。

1.2 为什么不能直接“全局拿用户”

很多初学者第一个直觉是:Django 不是有request.user吗?直接拿来用不就行了。

问题在于,Django Admin 的处理逻辑分散在不同层级。ModelAdmin里大量方法确实可以拿到request,这是最省事的地方;但业务逻辑一旦下沉到Model.save()、ModelForm的clean()、信号(signal)里,就完全拿不到request了。而这些地方恰恰是“自动填充用户”“审计日志”最需要生效的位置。

这里得说清楚一个底层事实:Django 的模型层(Model)和请求-响应层(Request/View)是刻意解耦的。模型对象只是一个数据封装,它不知道也不关心自己是被哪个视图、哪个请求、哪个用户触发的。这种设计在大多数场景下是优点,但在“记录操作人”这个需求上,就成了天然的障碍。

所以真正要解决的不是“怎么拿用户”,而是“在脱离 request 环境的地方,如何安全、可靠、不串号地拿到当前登录用户”。理解这个本质,后面再看各种方案就会清晰很多。

2. 六种主流获取方式的完整拆解

2.1 最正宗的方式:在 ModelAdmin 方法里用 request

如果你需要在 Admin 列表页、新增/编辑页、保存数据这些环节拿到当前用户,优先考虑在ModelAdmin的钩子方法里直接使用传入的request对象。这是 Django 官方设计的主流途径,也是最不容易出错的方式。

我整理了一份实际项目里最常用到的钩子方法清单:

Admin钩子方法触发时机适合做什么
get_queryset(request)每次查询列表数据时数据权限过滤,列表只显示当前用户可见的数据
save_model(request, obj, form, change)保存模型实例时自动填充创建人/更新人字段
delete_model(request, obj)删除模型实例时记录删除日志、校验删除权限
get_form(request, obj=None, **kwargs)构造表单时给表单传入当前用户,供自定义表单使用
formfield_for_foreignkey(field, request, **kwargs)渲染外键字段时限制外键下拉选项为当前用户有权选择的数据
formfield_for_manytomany(field, request, **kwargs)渲染多对多字段时同上,限制多对多可选范围
get_readonly_fields(request, obj=None)决定只读字段时根据当前用户角色,动态设置某些字段只读
get_list_display(request)决定列表列时按用户角色调整列表展示列
get_queryset之外的get_ordering、get_search_fields列表查询时按用户个性化排序或搜索范围
has_add_permission(request)/has_change_permission(request, obj=None)/has_delete_permission(request, obj=None)权限判定时做细粒度按钮级权限控制

下面这段代码是我在项目里写的一个标准示例,演示了在save_model和get_queryset中如何利用request.user实现“自动填充操作人 + 数据权限过滤”:

from django.contrib import admin from django.utils import timezone from .models import WorkOrder, DeviceInfo @admin.register(WorkOrder) class WorkOrderAdmin(admin.ModelAdmin): list_display = ("title", "owner", "created_by", "created_at", "updated_at") readonly_fields = ("created_by", "created_at", "updated_at") def save_model(self, request, obj, form, change): """保存时自动填充创建人与更新人信息""" if not change: # 新增场景:创建人、创建时间 obj.created_by = request.user obj.created_at = timezone.now() # 每次保存都刷新更新人和更新时间 obj.updated_by = request.user obj.updated_at = timezone.now() super().save_model(request, obj, form, change) def get_queryset(self, request): """数据权限:普通管理员只能看到自己创建的工单,超级管理员看全部""" qs = super().get_queryset(request) if request.user.is_superuser: return qs return qs.filter(created_by=request.user)

有几个关键细节要在代码之外额外强调。

第一,request.user一定是AbstractUser的实例或者AnonymousUser实例。在 Admin 后台里,未登录用户根本进不来,所以通常可以放心使用,但保险起见可以做非空判断。

第二,save_model里change参数标识当前操作是新增还是修改。change=False表示新增,change=True表示修改。如果你在修改时覆盖了created_by,操作人信息就丢了。正确做法是只在新增时写created_by,修改时只刷新updated_by。

第三,get_queryset里调用super().get_queryset(request)如果不传request,在部分 Django 版本里会有兼容性问题。早期版本允许不传,新版本严格要求必传,所以尽量显式传入,省得升级版本时候踩坑。

第四,只读字段里把created_by、created_at这些加进去,避免用户在表单里手动篡改。实际测试中发现,就算你不在readonly_fields里声明,只要表单里没有对应字段,用户也改不了;但加上更稳妥,也方便在编辑页里展示“这个工单是谁建的”。

这套方式的优点是清晰、显式、符合 Django 设计哲学,也方便在代码审查时一眼看出逻辑。缺点是无法覆盖Model.save()、信号、异步任务等脱离请求上下文的场景。所以它是我最推荐的第一选择,但不是唯一的方案。

2.2 最省事的全局方案:中间件 + ThreadLocal

场景很快就来了:你给某台服务器设备表加了一个“最近维护人”字段,希望不管从 Admin 后台哪个入口修改,都能自动带上当前操作人。如果数据模型上直接覆盖save()方法去拿用户,会发现根本拿不到。这时候就需要一种“全局获取当前用户”的手段。

我在实际项目里最终采用的方案是:自定义中间件 +threading.local。原理其实很简单:Django 处理每个请求时,都会经过中间件链;我在中间件里把当前请求对象或用户对象塞进一个线程私有的存储空间里,然后在任何地方——包括Model.save()、信号、表单校验——都能把这个用户取出来。

先看中间件的完整实现:

import threading # 线程私有存储 _thread_local = threading.local() class CurrentUserMiddleware: """将当前请求用户保存到线程局部变量中""" def __init__(self, get_response): self.get_response = get_response def __call__(self, request): # 在请求进入视图之前,把用户绑定到当前线程 _thread_local.user = getattr(request, "user", None) try: response = self.get_response(request) finally: # 请求结束后必须清理,否则线程复用会导致用户“串号” _thread_local.user = None return response def get_current_user(): """从线程局部变量中获取当前登录用户""" return getattr(_thread_local, "user", None)

然后在settings.py里把这个中间件注册到MIDDLEWARE列表里。注意要放在django.contrib.auth.middleware.AuthenticationMiddleware之后,因为我们要拿的request.user是经过认证中间件处理后的用户对象。顺序错了,拿到的一律是AnonymousUser。

MIDDLEWARE = [ # ... 其他中间件 "django.contrib.auth.middleware.AuthenticationMiddleware", "your_app.middleware.CurrentUserMiddleware", # ... 其他中间件 ]

有了这套东西,模型层写起来就非常舒服了。仍然以设备表为例:

from django.db import models from your_app.middleware import get_current_user class DeviceInfo(models.Model): name = models.CharField("设备名称", max_length=128) ip_address = models.GenericIPAddressField("IP地址") last_maintainer = models.ForeignKey( "auth.User", verbose_name="最近维护人", on_delete=models.SET_NULL, null=True, blank=True, ) updated_at = models.DateTimeField("更新时间", auto_now=True) def save(self, *args, **kwargs): # 在线程局部变量中获取当前用户 user = get_current_user() if user and user.is_authenticated: self.last_maintainer = user super().save(*args, **kwargs)

这里我要特别强调一个我踩过的大坑:线程局部变量必须清理。Django 开发服务器和部署环境(比如 gunicorn、uwsgi)都会复用线程,如果一个请求没有清理_thread_local.user,下一个请求拿到的就是上一个用户的对象。表现就是用户 A 修改了一条数据,数据库里记录的却是用户 B。这种问题极其隐蔽,排查起来非常让人头大。

再强调一次finally里的_thread_local.user = None是关键,不是可写可不写的锦上添花。我在云平台项目的测试环境里漏掉过,结果就是“用户串号”,被测试同事追着问了好久。

还有一点需要补充:如果使用异步视图或协程模式,threading.local的方案会失效,因为异步场景下同一个线程可能交错处理多个请求。Django 3.1 之后支持异步视图,但 Admin 后台基本还是同步为主的,所以这个方案在 Admin 场景下是安全的。如果将来有纯异步 API 的需求,建议单独设计上下文传递方案,不要硬套中间的件方案。

2.3 面向表单定制场景:把用户塞进 ModelForm

有些需求在save_model里处理起来比较别扭,最典型的是自定义ModelForm的clean()校验或save()逻辑。举个例子:工单模型里有一个外键“分派人”,提交时校验“申请人不能同时是分派人”,或者“分派人必须属于当前用户的团队”。这些逻辑天然写在表单里,而表单实例拿不到request。

方法很简单:在ModelAdmin.get_form里把request.user传进表单实例,或者通过formfield_for_foreignkey的request参数做外键过滤。

先看通过get_form传用户的标准写法:

class WorkOrderForm(forms.ModelForm): class Meta: model = WorkOrder fields = "__all__" def __init__(self, *args, **kwargs): # 从 kwargs 中取出当前用户,移除后再调用父类 self.current_user = kwargs.pop("current_user", None) super().__init__(*args, **kwargs) def clean(self): cleaned_data = super().clean() # 在 clean 阶段也能拿到 current_user user = self.current_user if user: applicant = cleaned_data.get("applicant") if applicant and applicant == user: raise forms.ValidationError("申请人与分派人不能是同一人") return cleaned_data @admin.register(WorkOrder) class WorkOrderAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): # 通过 kwargs 把当前用户传递到表单中 form = super().get_form(request, obj, **kwargs) form.current_user = request.user return form

这段代码里有个很隐蔽但很重要的细节:直接在get_form里改动态类属性form.current_user = request.user相当于给表单类挂了一个类变量。因为ModelAdmin.get_form返回的是一个表单类,不是表单实例,所有后续实例都会共享这个属性。如果某个请求先设置了用户 A,另一个请求又设置了用户 B,由于类属性是共享的,后设置的会覆盖先设置的,最终所有实例拿到的可能都是后设置的那个用户。

正确写法有两个方向。方向一是像上面代码里那样,在ModelForm.__init__中用kwargs.pop("current_user", None)只给当前实例绑定实例属性,不要动类属性。方向二是在get_form里返回一个动态生成的表单类,通过闭包把用户传进去。但方向一更简洁,也更易读,我强烈建议用方向一。

还有一种常见需求是“外键下拉框里只显示当前用户创建的数据”,这个不需要自定义表单,直接在formfield_for_foreignkey里处理即可:

def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == "related_order": # 只展示当前用户创建的工单 kwargs["queryset"] = WorkOrder.objects.filter(created_by=request.user) return super().formfield_for_foreignkey(db_field, request, **kwargs)

这套方案和方案一并不冲突。save_model适合处理“保存时自动带上操作人”这类比较扁平的逻辑;get_form+ModelForm适合处理“表单校验需要参考当前用户”这类复杂逻辑。两者结合使用,覆盖场景会非常完整。

2.4 面向信号场景:从 post_save 里拿用户

信号(signal)是 Django 里非常强大的工具,但也是“拿不到当前用户”的重灾区。我见过不少开发者想在post_save信号里记录操作人,写完才发现信号函数的签名里根本没有request,也没有用户信息。

信号函数的签名长这样:

from django.db.models.signals import post_save from django.dispatch import receiver @receiver(post_save, sender=WorkOrder) def work_order_saved(sender, instance, created, **kwargs): # 这里能拿到 instance,但拿不到“谁改了这条记录” pass

想要在信号里拿到用户,没有“官方正统”的手段,只有两个变通方向。第一个是在调用方显式传参,但信号机制本身不提供请求上下文,传参推进去也不太现实。第二个就是使用方案二里的中间件 +ThreadLocal,在信号函数里通过get_current_user()获取。

@receiver(post_save, sender=WorkOrder) def work_order_saved(sender, instance, created, **kwargs): user = get_current_user() if user and user.is_authenticated: ChangeLog.objects.create( target=instance, operator=user, action="create" if created else "update", )

这种方式有一个先决条件:中间件必须在请求处理链路上正确执行。如果信号是在请求外触发的(比如 Celery 异步任务里调用Model.save()),get_current_user()返回的就是None,代码里必须做好空值处理。我在项目里是这么处理的:有用户就记录操作人,没有用户就记录“系统触发”,这样既不会漏日志,也不会把异步任务误记成某个管理员的操作。

这里也顺便提醒一下,信号里做日志记录时特别注意递归问题。如果ChangeLog的保存也触发了某个信号,而那个信号里又记录日志,很容易造成无限循环。我的习惯是在记录操作日志的信号里,先断开信号接收器或者加一个跳过标记,否则线上会出现大量重复日志。

2.5 面向自定义视图:标准 request.user 依然最稳

有些需求是标准 Admin 满足不了的,需要自定义 Admin 视图。比如在工单详情页加一个“转派”按钮,在设备列表页加一个“批量导入”功能,或者在编辑页面加一个“手动刷新配置”的操作。这时候你会写自定义的视图函数或视图类,只要处理得当,request.user就是最直接、最正规的用户来源。

Django Admin 里自定义视图有两种常见姿势。一种是直接在ModelAdmin.get_urls()里注册自定义路由,另一种是单独写视图函数,再自己拼接 URL 挂到 Admin 站点下。这里分别演示。

先看get_urls的标准写法:

from django.shortcuts import render, redirect from django.urls import path from django.contrib import messages from django.contrib.admin import helpers @admin.register(DeviceInfo) class DeviceInfoAdmin(admin.ModelAdmin): def get_urls(self): urls = super().get_urls() custom_urls = [ path( "<int:device_id>/refresh-config/", self.admin_site.admin_view(self.refresh_config_view), name="device-refresh-config", ), ] return custom_urls + urls def refresh_config_view(self, request, device_id): # 这里直接使用 request.user,就是对的方式 if not request.user.has_perm("your_app.can_refresh_device_config"): self.message_user(request, "没有权限执行此操作", level="error") return redirect("admin:your_app_deviceinfo_changelist") device = DeviceInfo.objects.get(pk=device_id) # 模拟远程刷新配置 device.refresh_config(operator=request.user) self.message_user(request, f"设备 {device.name} 配置刷新已触发") return redirect("admin:your_app_deviceinfo_changelist")

再看不挂在 ModelAdmin 下的独立视图,这种方式适合功能相对独立、不想被 ModelAdmin 的权限体系干扰的场景:

from django.contrib.admin.views.decorators import staff_member_required from django.shortcuts import render from .models import ChangeLog @staff_member_required def operation_log_view(request): """自定义视图:查看当前用户的操作日志""" logs = ChangeLog.objects.filter(operator=request.user).select_related("target")[:100] return render(request, "admin/your_app/operation_log.html", {"logs": logs})

staff_member_required装饰器是 Admin 视图的标配,它会校验用户必须登录且is_staff == True,未通过的会被重定向到 Admin 登录页。用了这个装饰器之后,视图函数内部的request.user必然是一个真实登录的 Staff 用户,不需要再做匿名判断。

在自定义视图里还可能遇到表单处理。如果视图里用了ModelForm且表单里需要当前用户,跟方案三类似,可以在视图里初始化表单时传入用户对象。但这里比 Admin 环境下更自由,因为视图函数里直接拿着request,想怎么传都行。

2.6 面向权限判断:记住 is_active 与 is_staff 的差异

获取当前用户之后,紧随其来的就是权限判断。Admin 后台的权限体系有两个容易混淆的字段:is_active和is_staff,还有一个经常被忽略的方法has_perm()。

先说三个核心概念的区别:

用户属性/方法含义Admin 后台的意义
is_active账号是否被禁用为 False 的用户无法登录系统
is_staff是否可以进入 Admin 后台为 False 的用户无法访问任何 Admin 页面
is_superuser是否超级管理员为 True 时拥有所有权限,绕过权限校验
has_perm(perm_code)是否拥有指定权限精确判断某个操作按钮是否可显示/可用

实际项目里最常见的错误是只用is_superuser判断,导致非超管用户什么都做不了;或者只用is_staff判断,导致所有后台用户都能看到管理入口。我的建议是分层处理:进入 Admin 页面交给is_staff,页面内细分操作用has_perm(),特别重要的操作(比如删除设备、推送配置到生产环境)再加一层is_superuser判断。

def has_manage_permission(request, obj=None): """自定义的权限判断逻辑: - 超级管理员直接放行 - 普通管理员要求同时具备应用权限和对象归属权 """ user = request.user if user.is_superuser: return True if not user.is_staff or not user.is_active: return False if not user.has_perm("your_app.manage_device"): return False # 非超管只能操作自己名下设备 if obj and hasattr(obj, "owner") and obj.owner != user: return False return True

还有一个很容易被忽略的点:Django Admin 的权限判断既包括 View 层校验,也包括ModelAdmin的has_add_permission、has_change_permission、has_delete_permission三个方法。如果你希望在列表页直接隐藏“新增”“删除”按钮,而不是等到提交时报权限错误,就一定在这三个方法里主动判断。

def has_add_permission(self, request): if request.user.is_superuser: return True return request.user.has_perm("your_app.can_add_device") def has_delete_permission(self, request, obj=None): if request.user.is_superuser: return True # 非超管只能删除自己创建的设备 if obj and obj.created_by != request.user: return False return request.user.has_perm("your_app.can_delete_device")

3. 综合实战:给Admin加一套“谁改了什么”的审计日志

3.1 需求描述与整体思路

前面讲的全都是具体方法,这一节把它们串起来,做一个能直接在项目里复用的完整案例。

需求是这样:在云管理平台后台,要求对所有核心业务模型的操作进行审计——谁创建了工单、谁修改了设备配置、谁删除了监控规则、什么时间操作的、改了哪些字段。并且普通管理员只能看到自己的操作日志,超级管理员能看全部。

整体思路分成四层。第一层是数据层,设计一个通用的OperationLog模型,用 GenericForeignKey 指向任意被操作对象。第二层是采集层,通过 ModelAdmin 的save_model、delete_model钩子采集“谁操作的、操作了什么”。第三层是字段快照层,在save_model之前对比修改前后的字段值,记录变更详情。第四层是展示层,在 Admin 后台注册一个日志管理页面,按当前用户过滤展示。

3.2 数据模型设计与实现

from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.conf import settings class OperationLog(models.Model): ACTION_CREATE = "create" ACTION_UPDATE = "update" ACTION_DELETE = "delete" ACTION_CHOICES = [ (ACTION_CREATE, "创建"), (ACTION_UPDATE, "修改"), (ACTION_DELETE, "删除"), ] operator = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name="操作人", on_delete=models.SET_NULL, null=True, blank=True, ) action = models.CharField("操作类型", max_length=16, choices=ACTION_CHOICES) object_id = models.PositiveIntegerField("对象ID", null=True, blank=True) content_type = models.ForeignKey( ContentType, verbose_name="操作对象类型", on_delete=models.CASCADE, null=True, blank=True, ) target = GenericForeignKey("content_type", "object_id") change_detail = models.JSONField("变更详情", default=dict, blank=True) created_at = models.DateTimeField("操作时间", auto_now_add=True) class Meta: verbose_name = "操作日志" verbose_name_plural = "操作日志" ordering = ("-created_at",) class OperationLogAdmin(admin.ModelAdmin): list_display = ("operator", "action", "content_type", "object_id", "created_at") list_filter = ("action", "content_type") search_fields = ("operator__username", "change_detail") readonly_fields = ("operator", "action", "content_type", "object_id", "change_detail", "created_at") def get_queryset(self, request): qs = super().get_queryset(request) if request.user.is_superuser: return qs return qs.filter(operator=request.user) def has_add_permission(self, request): # 日志只允许查看,不允许手工新增 return False def has_change_permission(self, request, obj=None): # 日志不允许修改 return False def has_delete_permission(self, request, obj=None): # 日志不允许删除,只有超管可以删除 return request.user.is_superuser

日志模型里我用了一个JSONField存变更详情。比如工单标题从“A”变成“B”,就记录{"title": {"old": "A", "new": "B"}}。这样既能精确追踪每个字段的变化,又不需要为每张表单独建日志模型,通用性很强。

3.3 采集层与字段快照实现

采集层是核心。我写了一个混入类(Mixin),任何 ModelAdmin 继承它,就能自动获得操作日志采集能力。这里直接给出完整代码,重点看_snapshot_changes方法:

import json from django.contrib.contenttypes.models import ContentType from .models import OperationLog class OperationLogMixin: """为 ModelAdmin 增加自动操作日志能力""" def log_action(self, request, obj, action, change_detail=None): OperationLog.objects.create( operator=request.user, action=action, content_type=ContentType.objects.get_for_model(obj), object_id=obj.pk, change_detail=change_detail or {}, ) def save_model(self, request, obj, form, change): if change: # 修改前需要先拿到旧值 old_obj = self.model.objects.get(pk=obj.pk) changes = self._snapshot_changes(old_obj, obj, form) else: changes = {} super().save_model(request, obj, form, change) self.log_action(request, obj, "update" if change else "create", changes) def delete_model(self, request, obj): super().delete_model(request, obj) self.log_action(request, obj, "delete") def _snapshot_changes(self, old_obj, new_obj, form): changes = {} for field in form.changed_data: old_value = getattr(old_obj, field) new_value = getattr(new_obj, field) # 时间、外键等复杂类型统一转成字符串,方便 JSON 序列化 changes[field] = { "old": str(old_value) if old_value is not None else None, "new": str(new_value) if new_value is not None else None, } return changes

使用方式很简单,把OperationLogMixin混入对应的ModelAdmin类即可:

@admin.register(DeviceInfo) class DeviceInfoAdmin(OperationLogMixin, admin.ModelAdmin): list_display = ("name", "ip_address", "updated_at")

这一套组合拳下来,新增、修改、删除全都有日志了,而且不需要重复代码。字段快照部分用form.changed_data来对比,比直接遍历实例字段更精准——它能识别用户“改过但改成相同值”的场景,也能排除用户根本没碰过的字段。实际跑过之后发现这个细节对日志准确性帮助非常大。

3.4 各方案横向对比与选型参考

为了帮你在实际项目中快速做选型,我把六种方案的核心特点整理成一张表:

获取方式核心原理适用场景优点缺点
ModelAdmin钩子方法通过参数显式传递request列表过滤、保存填充、外键过滤官方推荐、显式清晰覆盖不到模型层/信号层
ThreadLocal中间件把用户存到线程私有空间Model.save()、信号、底层逻辑全局可用、代码侵入小必须清理、异步场景失效
表单注入用户通过表单实例传递用户ModelForm校验、复杂保存逻辑逻辑集中在表单、易测试需要手动传参
信号中使用ThreadLocal信号函数里读取线程变量post_save、post_delete解耦、适合日志场景脱离请求时拿不到用户
自定义视图使用request.user视图函数直接使用请求对象自定义按钮、自定义页面最标准、最直接只适用于自定义视图
权限判断与用户校验用is_staff/has_perm判断按钮权限、操作权限安全可靠、细粒度需要理解Django权限模型

如果你只想记住一个简版结论,我个人的选型习惯是这样的:

能在ModelAdmin钩子里解决的问题,绝不往下层拿用户;必须下沉到模型层或信号层时,再启用ThreadLocal中间件;自定义视图一律用request.user原始对象;权限控制统一走has_perm+is_staff组合。

4. 常见问题与排查技巧实录

4.1 “用户串号”问题:ThreadLocal 忘了清理

这是所有使用中间件方案的新手最容易踩的坑,症状是诡异的“数据错乱”——A 用户新建了一条数据,日志里记录的却是 B 用户;或者 A 用户刷新页面,看到的是 B 用户的数据。

原因是当前 Django 部署环境复用了线程,而_thread_local.user只往里面塞值、从不清理。上一个请求设置的 user 被下一个请求原样读了出来。排查思路是这样:先检查中间件里是否有finally清理逻辑,再看是不是在异步任务或多线程代码里误用了这个变量。

修复起来不难,但要注意清理动作的写法。

def __call__(self, request): _thread_local.user = getattr(request, "user", None) try: response = self.get_response(request) finally: _thread_local.user = None return response

4.2 中间件顺序不对导致拿不到用户

还有一次,我在项目里把自定义中间件放到了AuthenticationMiddleware前面,结果get_current_user()返回的全是None或AnonymousUser。原因很简单:认证中间件还没执行,request.user还是空壳,你提前把它塞进线程变量,自然拿不到东西。

排查方式:在中间件里打印request.user的类名,如果显示AnonymousUser,大概率是顺序问题。把自定义中间件放到AuthenticationMiddleware之后,问题立刻消失。

4.3 在异步任务里拿不到用户

Celery 异步任务里执行Model.save()不会经过 HTTP 中间件,get_current_user()返回None。这是 ThreadLocal 方案的天花板,不要试图硬刚。

务实的做法是在任务入口把用户显式传进去。举例来说,如果某个操作需要异步生成报表,创建任务前把request.user.id作为参数传进去,任务内部查库拿到真实用户对象,然后继续执行。这样既避免了“后台任务把操作人都记成 None”的问题,也保持了异步任务的独立性。

4.4 在 ModelForm.clean() 里拿不到用户

ModelForm的clean()方法拿不到request是设计使然。如果遇到这种需求,按照方案三的思路,在get_form里把current_user传进去,然后通过kwargs.pop("current_user", None)转成实例属性。注意千万不要直接修改form.current_user这个类属性,否则会出现线程安全问题。

4.5 权限判断用错用户类型

最后一个常见问题是权限判断不够严谨。直接判断request.user.is_staff就放行所有后台操作,会导致低权限用户也能看到并操作高权限功能。

正确姿势我前面已经给过代码:能进入后台靠is_staff,细分操作用has_perm(),关键高危操作叠加is_superuser判断。有一项容易被忽略的是:has_perm()的判断结果依赖当前请求的用户对象,不要在图里缓存用户权限或在进程启动时保存权限快照,否则用户权限变动后后台展示依然旧。

5. 项目里的实践体会与收尾

这次在云管理平台项目里做用户审计模块,我从“代码能跑”到“逻辑可靠”之间来回改了三轮,核心感悟是:Django Admin 获取当前用户这件事本身不难,难的是选对场景、用对方式、考虑到线程安全和异步边界。

我个人特别推荐“三段式组合”方案:第一,所有能通过ModelAdmin钩子拿request的地方,直接显式使用request.user,这是最不容易出错、最利于代码审查的方式;第二,需要下沉到模型层自动填充操作人或记录日志时,引入ThreadLocal中间件作为全局用户通道;第三,异步任务和脱离请求的场景,坚决不依赖中间件,显式传用户。

还要记住一点:好方案不是越复杂越好,而是越“符合 Django 设计哲学”越好。先想清楚代码执行在哪一层,再决定用哪种方式拿用户。大多数所谓“拿不到用户”的尴尬局面,其实都是因为把请求层和模型层混为一谈了。

最后分享一个小经验。在给模型字段自动填充用户时,千万不要直接用request.user对象去赋值然后save()。我在早期版本这么写过,结果遇到用户被删除的时候就报外键错误。正确做法是:优先存request.user.id或request.user.pk,然后在save()时用self.user_id = request.user.id做赋值。这样既避免缓存一个完整的用户对象,也减少因用户删除引发的关联问题。

这套东西现在还在云平台的后台稳定跑着,每天记录上千条操作日志,没有串号,没有丢日志,也没有出现权限越级。如果你也在折腾 Django Admin 的用户获取问题,希望这篇能把思路理顺,让你少走点弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进