Django 为什么建议一开始就自定义 User 模型?怎么做?
简化版
Django 官方文档用了非常强烈的措辞:「强烈建议即使默认的 User 模型足够用,也要在新项目开始时就设置自定义用户模型」。原因很实际:用户模型几乎必然会变(要加手机号、要用邮箱登录、要加租户字段),而项目上线后再替换 AUTH_USER_MODEL 极其痛苦——因为几乎所有表都通过外键指向了 auth.User,换模型意味着要重建这些外键、迁移全部数据、处理权限和 session,Django 官方也承认这「不受支持且很困难」。三种做法:① AbstractUser(最常用)——继承 Django 自带的完整用户模型(用户名、邮箱、密码、权限、is_staff/is_active),只加自己的字段,改动最小;② AbstractBaseUser——只继承「密码 + last_login」的骨架,登录字段、必填字段、Manager 全部自己定义(想用邮箱当账号、完全不要用户名时选它,通常还要配 PermissionsMixin);③ 一对一 Profile 表——不改 User,另建一张表关联(改动最小但每次取用户信息都多一次 JOIN,且无法改变登录字段)。做法的核心只有三步:定义模型 → 在 settings 里设 AUTH_USER_MODEL = "app.User" → 在第一次 migrate 之前完成。引用用户模型也有讲究:模型的外键里用 settings.AUTH_USER_MODEL(字符串,避免循环导入),运行时代码里用 get_user_model()——直接 from django.contrib.auth.models import User 是错的,那样在换了用户模型后会指向错误的类。核心记忆:新项目第一天就设 AUTH_USER_MODEL;大多数场景用 AbstractUser;外键用 settings 常量、代码里用 get_user_model()。
详细版
三种方案对比:
| 方案 | 继承什么 | 适合 | 代价 |
|---|---|---|---|
AbstractUser | 完整用户模型 | 绝大多数项目(加字段) | 保留 username 字段 |
AbstractBaseUser | 仅密码 + last_login | 改登录字段(邮箱/手机号登录) | 要自己写 Manager 和字段 |
| 一对一 Profile | 不改 User | 遗留项目、字段很少 | 每次都要 JOIN、改不了登录字段 |
直接用 auth.User | —— | ❌ 不推荐 | 后期无法扩展 |
# ① ★方案一:AbstractUser(最常用)★
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
phone = models.CharField(max_length=20, blank=True, db_index=True)
avatar = models.URLField(blank=True)
tenant = models.ForeignKey("Tenant", null=True, blank=True,
on_delete=models.PROTECT)
class Meta:
db_table = "accounts_user"
# settings.py —— ★必须在第一次 migrate 之前设置★
AUTH_USER_MODEL = "accounts.User"
# ② ★方案二:AbstractBaseUser(用邮箱登录、不要 username)★
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
class UserManager(BaseUserManager):
use_in_migrations = True
def create_user(self, email, password=None, **extra):
if not email:
raise ValueError("邮箱必填")
email = self.normalize_email(email) # ★域名转小写★
user = self.model(email=email, **extra)
user.set_password(password) # ★★绝不能直接赋值 password★★
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra):
extra.setdefault("is_staff", True)
extra.setdefault("is_superuser", True)
return self.create_user(email, password, **extra)
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True) # ★登录字段必须 unique★
name = models.CharField(max_length=50, blank=True)
is_staff = models.BooleanField(default=False) # ★AbstractBaseUser 不带★
is_active = models.BooleanField(default=True)
date_joined = models.DateTimeField(auto_now_add=True)
objects = UserManager()
USERNAME_FIELD = "email" # ★用什么登录★
REQUIRED_FIELDS = ["name"] # ★createsuperuser 会额外问★
def __str__(self): return self.email
# ③ ★引用用户模型的三种正确方式★
# 模型外键里 → ★字符串常量(避免循环导入)★
class Order(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
# 运行时代码里 → ★get_user_model()★
from django.contrib.auth import get_user_model
User = get_user_model()
User.objects.filter(...)
# 类型注解 / 需要延迟求值时
def f(u: "AbstractBaseUser"): ...
# ✗ ★错误:硬编码导入★
from django.contrib.auth.models import User # ★换了模型就指向错的类★
# ④ admin 注册
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@admin.register(User)
class UserAdmin(BaseUserAdmin):
fieldsets = BaseUserAdmin.fieldsets + (("扩展", {"fields": ("phone",)}),)
⚠️ 三个必须记住的点:①
AUTH_USER_MODEL必须在项目的第一次migrate之前设置好。一旦执行过迁移,auth、admin、sessions、以及你自己所有指向用户的外键都已经在数据库里建立,之后再改这个设置,Django 会要求你重建大量外键、迁移数据、处理权限表——官方文档明确说明「更改AUTH_USER_MODEL在项目中期是不受支持的,会带来严重困难」。所以哪怕现在完全不需要额外字段,也要先建一个空的class User(AbstractUser): pass,成本几乎为零,却省掉了未来可能几天的痛苦。② 绝不能直接给password字段赋值:user.password = "123456"存进去的是明文,登录时永远验证失败(因为 Django 会用哈希算法比对)。正确做法是user.set_password(raw)(内部用 PBKDF2 等算法加盐哈希),验证用user.check_password(raw);批量创建用户时也要逐个set_password,bulk_create不会帮你处理。③ 引用用户模型有严格的规则:模型定义里(外键、多对多)必须用settings.AUTH_USER_MODEL这个字符串——因为模型加载时用户模型可能还没就绪,用字符串可以延迟解析、也避免循环导入;运行时代码(视图、Manager、脚本)里用get_user_model()。直接from django.contrib.auth.models import User是最常见的错误——项目换了用户模型后,这行代码仍然指向 Django 自带的auth.User,导致查询查到空表、外键关联错误。
完整版教学
一、为什么必须一开始就做
★ Django 官方文档的原话(措辞很重):
"It's highly recommended to set up a custom user model,
even if the default User model is sufficient for you."
"Changing AUTH_USER_MODEL after you've created database tables is
significantly more difficult... this change can't be done automatically."
★ 为什么中途替换这么难:
① ★大量外键指向 auth_user★
- 你自己的表:Order.user、Comment.author、Log.operator…
- Django 内置:admin_log(操作日志)、
auth_user_groups、auth_user_user_permissions
→ 换模型 = ★所有这些外键都要指向新表★
② ★数据迁移的复杂性★
- 要把 auth_user 的数据搬到新表(★保持 id 不变才能不破坏外键★)
- 权限、组的关联关系也要搬
- ★中途的失败很难回滚★
③ ★第三方包的影响★
很多包在迁移文件里★硬编码了对 auth.User 的依赖★
→ 换模型后它们的历史迁移可能无法重放
④ ★content_type 和权限★
Permission 表通过 content_type 关联到模型
→ 换模型意味着 content_type 变了
★ 成本对比(★这就是为什么要"第一天就做"★):
项目开始时:
class User(AbstractUser): pass # ★3 行代码 + 1 行 settings★
AUTH_USER_MODEL = "accounts.User"
→ ★成本 ≈ 0★
上线一年后:
→ 停机窗口、数据迁移脚本、外键重建、
全量回归测试、失败回滚方案
→ ★成本 = 数天到数周★
★ 未来"必然会遇到"的需求(★所以别赌"我不需要"★):
- 加手机号 / 微信 openid / 企业微信 userid
- ★用邮箱或手机号登录(不用 username)★
- 多租户:加 tenant 外键
- 加实名信息、头像、偏好设置
- 软删除用户 / 用户状态机
- 修改 username 的长度或校验规则
★ 这些用一对一 Profile 表能解决一部分,但★登录字段★改不了
★ 一句话结论:
★新项目第一天就写 class User(AbstractUser): pass,
哪怕它现在是空的。★
Django 官方文档用了非常强烈的措辞推荐自定义用户模型,因为中途替换极其困难,原因有四:大量外键指向 auth_user(你自己的表加上 Django 内置的 admin_log、权限关联表)、数据迁移复杂(要保持 id 不变才不破坏外键,中途失败很难回滚)、第三方包在迁移文件里硬编码了对 auth.User 的依赖、以及 content_type 和权限表的关联也要重建。成本对比非常悬殊:项目开始时只要 3 行代码加 1 行 settings,成本约等于零;上线一年后则是数天到数周的迁移工程(还要停机窗口和回滚方案)。而「用户模型会变」几乎是必然的——加手机号、加租户、改成邮箱登录、加实名信息……其中登录字段的改变用一对一 Profile 表根本解决不了。所以结论很简单:新项目第一天就写 class User(AbstractUser): pass,哪怕它现在是空的。
二、AbstractUser:最常用的方案
★ AbstractUser 自带什么:
username ★唯一,有字符校验器★
first_name / last_name / email
password ★哈希后的密码★
is_staff 能否登录 admin
is_active ★账号是否启用(禁用后无法登录)★
is_superuser 超级用户(★绕过所有权限检查★)
last_login / date_joined
groups / user_permissions ★权限系统(来自 PermissionsMixin)★
+ 方法:set_password / check_password / has_perm / get_full_name …
★ 完整实现(★三步★):
# ① accounts/models.py
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
phone = models.CharField(max_length=20, blank=True, db_index=True)
avatar = models.URLField(blank=True)
# ★可以覆盖父类字段★
email = models.EmailField(unique=True) # ★让 email 唯一(父类不唯一)★
# ② settings.py
AUTH_USER_MODEL = "accounts.User"
# ③ ★在第一次 migrate 之前★
python manage.py makemigrations accounts
python manage.py migrate
★ 常见的定制:
① ★让 email 唯一并用它登录(但保留 username 字段)★
class User(AbstractUser):
email = models.EmailField(unique=True)
USERNAME_FIELD = "email" # ★用 email 登录★
REQUIRED_FIELDS = ["username"] # ★createsuperuser 仍会问★
★ 注意:USERNAME_FIELD 指定的字段★必须 unique★,
且★不能出现在 REQUIRED_FIELDS 里★
② 去掉 username(★这时更适合 AbstractBaseUser★)
username = None
USERNAME_FIELD = "email"
objects = CustomUserManager() # ★必须自定义(默认的要 username)★
③ 放宽 username 的字符限制
username_validator = UnicodeUsernameValidator() # 默认已支持 Unicode
username = models.CharField(max_length=150, unique=True,
validators=[my_validator])
★ admin 的适配(★不做的话新字段不显示★):
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@admin.register(User)
class UserAdmin(BaseUserAdmin):
# ★在原有 fieldsets 基础上追加★
fieldsets = BaseUserAdmin.fieldsets + (
("扩展信息", {"fields": ("phone", "avatar")}),
)
add_fieldsets = BaseUserAdmin.add_fieldsets + (
(None, {"fields": ("phone",)}),
)
list_display = ("username", "email", "phone", "is_staff")
search_fields = ("username", "email", "phone")
★ 表单的适配:
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
class MyUserCreationForm(UserCreationForm):
class Meta(UserCreationForm.Meta):
model = User # ★指向自定义模型★
fields = UserCreationForm.Meta.fields + ("phone",)
AbstractUser 是绝大多数项目的选择:它自带完整的用户模型(username、email、password、is_staff/is_active/is_superuser、权限关联、set_password/has_perm 等方法),你只需要继承并加自己的字段。实现只有三步:定义模型 → settings 里设 AUTH_USER_MODEL → 在第一次 migrate 之前完成。常见定制包括:让 email 唯一并设为 USERNAME_FIELD(注意 USERNAME_FIELD 指定的字段必须 unique,且不能出现在 REQUIRED_FIELDS 里)、放宽 username 的字符校验。别忘了适配 admin——不重写 UserAdmin 的话,你新加的字段不会显示在 admin 编辑页(因为父类的 fieldsets 是写死的),正确做法是在 BaseUserAdmin.fieldsets 基础上追加而不是整个重写。表单同理,UserCreationForm/UserChangeForm 的 Meta.model 要指向自定义模型。
三、AbstractBaseUser:完全自定义
★ AbstractBaseUser 只提供:
password / last_login
+ 密码相关方法:set_password / check_password / has_usable_password
★ 其余全部要自己定义★:登录字段、is_active、is_staff、权限…
★ 什么时候选它:
✓ ★完全不要 username★(用邮箱/手机号做唯一标识)
✓ 字段结构与默认差异很大(多租户下"同租户内邮箱唯一")
✓ 不想要 Django 的权限系统(不继承 PermissionsMixin)
✗ 只是想加几个字段 → ★用 AbstractUser 就够了★
★ 完整实现(★四个必需组件★):
① ★自定义 Manager(必需)★
class UserManager(BaseUserManager):
use_in_migrations = True # ★让迁移能用它★
def create_user(self, email, password=None, **extra):
if not email: raise ValueError("邮箱必填")
user = self.model(email=self.normalize_email(email), **extra)
user.set_password(password) # ★关键★
user.save(using=self._db) # ★using=self._db 支持多库★
return user
def create_superuser(self, email, password=None, **extra):
extra.setdefault("is_staff", True)
extra.setdefault("is_superuser", True)
if extra.get("is_staff") is not True:
raise ValueError("超级用户必须 is_staff=True")
return self.create_user(email, password, **extra)
② ★模型定义★
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True) # ★必须 unique★
name = models.CharField(max_length=50, blank=True)
is_active = models.BooleanField(default=True) # ★认证流程要用★
is_staff = models.BooleanField(default=False) # ★admin 要用★
date_joined = models.DateTimeField(auto_now_add=True)
objects = UserManager()
USERNAME_FIELD = "email" # ★③ 用什么登录★
EMAIL_FIELD = "email" # 发邮件用哪个字段
REQUIRED_FIELDS = ["name"] # ★④ createsuperuser 额外问的★
③ ★三个类属性的含义(★高频考点★)★:
USERNAME_FIELD ★登录时用的字段★,必须 unique
REQUIRED_FIELDS ★createsuperuser 命令会额外提示输入的字段★
★不包含 USERNAME_FIELD 和 password★
EMAIL_FIELD 密码重置等功能取邮箱用哪个字段
④ ★PermissionsMixin 提供什么★:
is_superuser / groups / user_permissions
+ has_perm / has_perms / has_module_perms
★ 不继承它 = 放弃 Django 的权限系统(admin 也用不了)
★ 常见的坑:
✗ ★忘了 is_active★ → ModelBackend 认证时会报错/永远登录失败
(认证流程会检查 user.is_active)
✗ ★忘了 is_staff★ → 无法登录 admin
✗ ★Manager 里直接赋值 password★ → 存明文,永远登不上
✗ ★USERNAME_FIELD 的字段没加 unique★ → 系统检查会报错(auth.E003)
✗ ★REQUIRED_FIELDS 里包含了 USERNAME_FIELD★ → 报错(auth.E002)
✗ 忘了 use_in_migrations = True → 数据迁移里用不了 create_user
★ 运行系统检查验证:
python manage.py check
→ Django 会检查 auth.E001~E00x 一系列用户模型相关的问题
AbstractBaseUser 只提供 password 和 last_login 加上密码相关方法,其余全部自己定义——适合「完全不要 username」或字段结构差异很大的场景。实现有四个必需组件:自定义 Manager(create_user/create_superuser,必须用 set_password,加 use_in_migrations = True 让数据迁移能用它)、模型定义(登录字段必须 unique,别忘了 is_active 和 is_staff)、三个类属性(USERNAME_FIELD 是登录字段、REQUIRED_FIELDS 是 createsuperuser 额外询问的字段且不能包含前者、EMAIL_FIELD 用于密码重置)、以及 PermissionsMixin(提供 is_superuser/groups/user_permissions 和 has_perm,不继承就等于放弃 Django 的权限系统和 admin)。常见的坑:忘了 is_active 会导致认证永远失败(认证流程会检查它)、忘了 is_staff 无法登录 admin、Manager 里直接赋值 password 存的是明文。写完记得跑 python manage.py check——Django 有一整套 auth.E001~E00x 的系统检查会告诉你哪里配错了。
四、引用用户模型的正确方式
★ 三种场景,三种写法(★不能混用★):
① ★模型定义里(外键/多对多)→ settings.AUTH_USER_MODEL★
from django.conf import settings
class Order(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="orders")
★ 为什么用字符串:
- 模型加载顺序不确定,★直接 import 可能循环导入★
- 字符串会被 Django ★延迟解析★
- 迁移文件里也会正确记录依赖
② ★运行时代码里(视图/Manager/脚本)→ get_user_model()★
from django.contrib.auth import get_user_model
User = get_user_model()
User.objects.filter(is_active=True)
★ 注意:★不要在模块顶层调用★(可能在 app 加载完成前执行)
→ 放在函数内部,或者用 django.apps.apps.get_model 延迟获取
③ ★类型注解 → 字符串或 TYPE_CHECKING★
if TYPE_CHECKING:
from accounts.models import User
def f(u: "User"): ...
★ ✗ 绝对错误的写法:
from django.contrib.auth.models import User # ★硬编码★
→ 项目换了用户模型后,★这行仍指向 Django 自带的 auth.User★
→ 症状:查询查到空表、外键关联到错误的表、
isinstance(request.user, User) 永远 False
★ 这是自定义用户模型后最常见的 bug
★ 第三方包的兼容性:
好的包会用 settings.AUTH_USER_MODEL / get_user_model()
★ 检查方法:装包前 grep 一下有没有硬编码的 auth.models.User
★ DRF、django-allauth、simple-jwt 等主流包都正确支持
★ 相关:request.user 的类型
request.user 可能是:
- 你的 User 实例(已登录)
- ★AnonymousUser★(未登录)
✓ 判断:if request.user.is_authenticated:
✗ if request.user: # ★AnonymousUser 的真值是 True!★
✗ if request.user is not None:
★ 在迁移文件里引用用户模型:
migrations.swappable_dependency(settings.AUTH_USER_MODEL)
→ makemigrations ★会自动生成★,不用手写
→ 它保证迁移顺序正确(用户模型先创建)
引用用户模型有三种场景三种写法,不能混用:模型定义里的外键必须用 settings.AUTH_USER_MODEL 字符串(模型加载顺序不确定,直接 import 可能循环导入;字符串会被 Django 延迟解析,迁移文件也能正确记录依赖);运行时代码用 get_user_model()(注意不要在模块顶层调用,可能在 app 加载完成前执行);类型注解用字符串或 TYPE_CHECKING。绝对错误的是 from django.contrib.auth.models import User——项目换了用户模型后这行仍指向 Django 自带的 auth.User,症状是查询查到空表、外键关联到错误的表、isinstance 判断永远为 False,这是自定义用户模型后最常见的 bug。还有个高频细节:request.user 未登录时是 AnonymousUser 而不是 None,而 AnonymousUser 的真值是 True——所以判断登录状态必须用 request.user.is_authenticated。
五、一对一 Profile 方案
★ 什么时候用(★它不是"更差",只是适用场景不同★):
✓ ★已经上线、无法更换 AUTH_USER_MODEL★
✓ 扩展字段属于"另一个领域"(如员工档案、商家资质)
✓ 字段很多但只有少数场景用到(★避免主表变宽★)
✗ 需要改登录字段 → ★Profile 做不到★
✗ 每个请求都要用到扩展字段 → ★每次多一次 JOIN★
★ 实现:
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile", # ★user.profile★
primary_key=True) # ★用 user_id 做主键★
phone = models.CharField(max_length=20, blank=True)
avatar = models.URLField(blank=True)
# ★自动创建 Profile(信号)★
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
★ 但信号有坑(见 Signals 专题):
- ★批量创建(bulk_create)不触发信号★ → Profile 缺失
- 测试里创建用户会多一次写
✓ 更稳的做法:用 get_or_create 惰性创建
def get_profile(user):
profile, _ = Profile.objects.get_or_create(user=user)
return profile
★ 性能注意:
✗ for u in User.objects.all(): print(u.profile.phone) # ★N+1★
✓ User.objects.select_related("profile") # ★1 次 JOIN★
★ 这就是"每次都要 JOIN"的代价——如果几乎每个页面都要 profile,
不如直接放进 User 模型
★ 三种方案的选择流程:
┌──────────────────────────────────────────────────┐
│ 新项目? │
│ → ★AbstractUser(默认选择)★ │
│ → 要改登录字段/结构差异大 → ★AbstractBaseUser★ │
│ 已上线且不能停机? │
│ → ★Profile 一对一(唯一现实选择)★ │
│ 已上线但有停机窗口 + 必须改登录字段? │
│ → 走艰难的迁移路径(见下节) │
└──────────────────────────────────────────────────┘
★ 混合方案(实践中很常见):
★核心字段放 User(登录、状态、租户),业务档案放 Profile★
→ 核心字段每次都要 → 不 JOIN
→ 档案字段偶尔用 → 需要时 select_related
一对一 Profile 不是「更差的方案」,只是适用场景不同:已经上线无法更换 AUTH_USER_MODEL 时它是唯一现实选择,另外「扩展字段属于另一个领域」(员工档案、商家资质)或「字段很多但少用」时它也合理。但两个硬限制要清楚:改不了登录字段、每次访问扩展字段都要多一次 JOIN(for u in User.objects.all(): u.profile.phone 就是 N+1,必须 select_related("profile"))。实现上常用信号在用户创建时自动建 Profile,但信号有坑(bulk_create 不触发信号会导致 Profile 缺失),更稳的做法是用 get_or_create 惰性创建。实践中很常见的是混合方案:核心字段(登录、状态、租户)放 User,业务档案放 Profile——前者每次都要用所以不 JOIN,后者偶尔用时再 select_related。
六、已上线项目的迁移路径
★ 前提:这条路★官方不支持★,只能手工操作,且风险高
→ 先确认:★真的必须换吗?Profile 方案能否满足?★
★ 相对可行的路径(★需要停机窗口★):
① ★准备阶段★
- 全量备份数据库(★可回滚是底线★)
- 在测试环境完整演练一遍
- 统计所有指向 auth.User 的外键(包括第三方包的表)
② ★创建新模型(保持表结构兼容)★
class User(AbstractUser):
class Meta:
db_table = "auth_user" # ★★复用原表!这是关键技巧★★
AUTH_USER_MODEL = "accounts.User"
→ 因为 AbstractUser 的字段和 auth_user 完全一致,
★指向同一张表就不需要迁移数据★
③ ★处理迁移文件★
- 删除/重写涉及 auth.User 外键的历史迁移(★或 squash★)
- 新建 accounts 的 initial 迁移,用 ★--fake★ 标记为已应用
- 让 Django 的迁移状态与实际数据库一致
④ ★修复外键的 content_type 和权限★
- django_content_type 表里 app_label/model 要更新
- auth_permission 关联的 content_type_id
⑤ ★全量回归 + 上线★
★ 关键技巧:★db_table = "auth_user"★
让新模型直接使用原来的表 → ★避免数据迁移★
→ 之后再逐步加字段(正常的 makemigrations 即可)
★ 更保守的替代方案:
★不换 AUTH_USER_MODEL,用 Profile 扩展★
→ 牺牲:改不了登录字段
→ 但可以用 ★自定义认证后端★ 实现"邮箱/手机号登录":
class EmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kw):
User = get_user_model()
try:
user = User.objects.get(Q(email=username) | Q(username=username))
except User.DoesNotExist:
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
# settings.py
AUTHENTICATION_BACKENDS = ["accounts.backends.EmailBackend",
"django.contrib.auth.backends.ModelBackend"]
★ 这样不改模型也能实现"用邮箱登录"★——很多场景下够用了
★ 检查清单(迁移前必做):
□ ★数据库全量备份 + 验证可恢复★
□ 测试环境完整演练(含回滚)
□ 列出所有指向 User 的外键(自己的 + 第三方的)
□ 确认第三方包是否用 ★settings.AUTH_USER_MODEL★
□ 代码里 grep ★from django.contrib.auth.models import User★
□ 停机窗口和回滚方案
□ 迁移后跑 ★python manage.py check★ 和全量测试
★ 一句话总结这一节:
★"能不迁就不迁——这正是'新项目第一天就自定义 User'的价值所在。"★
已上线项目的迁移官方不支持、只能手工操作、风险很高,所以第一步是确认「真的必须换吗」。相对可行的路径需要停机窗口:备份和演练 → 创建新模型时用 db_table = "auth_user" 复用原表(这是关键技巧——AbstractUser 的字段和 auth_user 完全一致,指向同一张表就不需要迁移数据)→ 处理迁移文件(用 --fake 让迁移状态与实际数据库一致)→ 修复 content_type 和权限表 → 全量回归。更保守的替代方案是不换模型、用自定义认证后端实现「邮箱/手机号登录」——写一个继承 ModelBackend 的类,在 authenticate 里用 Q(email=...) | Q(username=...) 查找用户,注册到 AUTHENTICATION_BACKENDS 即可,很多场景下这就够了。这一节的结论其实是在反证前面的观点:能不迁就不迁——这正是「新项目第一天就自定义 User」的价值所在。
记忆钩子:「★Django 官方强烈建议新项目第一天就设置自定义用户模型★,哪怕它是空的
class User(AbstractUser): pass——因为★项目中期更换 AUTH_USER_MODEL 官方明确说『不受支持且非常困难』★:所有指向 auth_user 的外键(自己的 + admin_log + 权限关联表 + 第三方包)都要重建、数据要保持 id 不变地迁移、content_type 和权限也要修复;★成本从『3 行代码』变成『数天到数周 + 停机窗口』★。三种方案:★AbstractUser(默认选择)★——继承完整用户模型只加字段;★AbstractBaseUser★——只有 password + last_login,适合『完全不要 username、用邮箱/手机号登录』,必须自己写 ★Manager(create_user 里用 set_password、加 use_in_migrations=True)★、自己加 ★is_active(不加会导致认证永远失败)和 is_staff(不加登不了 admin)★、并配 ★PermissionsMixin★ 才有权限系统;★一对一 Profile★——已上线项目的唯一现实选择,但★改不了登录字段、每次访问都要 JOIN★(记得 select_related 防 N+1)。三个类属性:★USERNAME_FIELD 是登录字段(必须 unique)、REQUIRED_FIELDS 是 createsuperuser 额外问的(不能包含 USERNAME_FIELD)、EMAIL_FIELD 用于密码重置★。★引用用户模型的规则不能混★:模型外键用 ★settings.AUTH_USER_MODEL 字符串★(延迟解析、避免循环导入、迁移能记录依赖)、运行时代码用 ★get_user_model()★(★别在模块顶层调用★)、★绝不能 from django.contrib.auth.models import User★(换模型后仍指向 auth.User,症状是查空表/关联错表)。其他易错:★password 必须 set_password 不能直接赋值★(否则存明文、永远登不上,bulk_create 也不会帮你哈希)、★request.user 未登录时是 AnonymousUser 而它的真值是 True★(判断要用 is_authenticated)、★自定义后 admin 要在 BaseUserAdmin.fieldsets 基础上追加★否则新字段不显示。已上线还想换?★用 db_table=‘auth_user’ 复用原表避免数据迁移★,或者干脆★写自定义认证后端实现邮箱登录★(不改模型)。」
七、常见误区与追问
- 误区:默认的
User模型够用,等真需要扩展时再改也不迟。 这是最昂贵的误判。Django 官方文档明确写着「即使默认的 User 模型足够用,也强烈建议设置自定义用户模型」,并直言**「在创建数据库表之后更改AUTH_USER_MODEL要困难得多……这个改动无法自动完成」。困难来自四个方面:你自己所有指向用户的外键都要重建、Django 内置的admin_log/auth_user_groups/auth_user_user_permissions也指向旧表、数据迁移必须保持 id 不变否则外键全废、django_content_type和auth_permission的关联要同步修复**;此外很多第三方包的历史迁移里硬编码了对auth.User的依赖,换模型后可能无法重放。而项目开始时的成本只有class User(AbstractUser): pass加一行 settings——几乎为零的保险费,对冲的是未来数天到数周的迁移工程。 - 误区:自定义了用户模型之后,代码里
from django.contrib.auth.models import User照常能用。 能 import 但指向的是错的类——那是 Django 自带的auth.User,而你的项目用的是accounts.User。症状很有迷惑性:查询不报错但查到的是空表(auth_user表可能压根不存在或没有数据)、建外键关联到了错误的表、isinstance(request.user, User)永远返回False、User.objects.create_user()创建的用户无法登录。正确规则是三分场景:模型定义(外键、多对多)里用settings.AUTH_USER_MODEL字符串(延迟解析、避免循环导入、迁移文件能正确记录依赖);运行时代码用get_user_model()(且不要在模块顶层调用,那可能在 app registry 就绪前执行而报错);类型注解用字符串或放在if TYPE_CHECKING:里。引入第三方包前也值得 grep 一下有没有这种硬编码。 - 误区:创建用户时
user.password = "123456"就设置好密码了。 这样存进数据库的是明文字符串,而 Django 登录时会把用户输入的密码做哈希后与数据库里的哈希值比对——明文永远匹配不上,表现为「用户创建成功但怎么都登不上」。必须用user.set_password(raw_password):它会用配置的哈希算法(默认 PBKDF2,可配 Argon2/bcrypt)加盐哈希后再存;校验用user.check_password(raw)。几个连带要点:create_user()内部已经调用了set_password(所以推荐用它而不是objects.create());bulk_create()不会帮你哈希,批量导入用户必须逐个set_password或预先算好哈希;修改密码后要调用update_session_auth_hash(request, user),否则当前用户的会话会失效被强制登出。 - 误区:
if request.user:可以判断用户是否登录。AnonymousUser的真值是True,所以这个判断永远成立——未登录用户也会被当成已登录,是个典型的越权漏洞。Django 的设计是:request.user在未登录时是AnonymousUser实例(不是None),它实现了和User相同的接口以便模板和代码统一处理(is_authenticated为False、is_anonymous为True、has_perm()恒为False)。正确判断是if request.user.is_authenticated:(注意 Django 1.10 起它是属性而不是方法,写成is_authenticated()在新版本里会得到一个恒为真的方法对象——又一个隐蔽的越权)。视图层更推荐用@login_required装饰器或LoginRequiredMixin。 - 误区:用
AbstractBaseUser时,只要定义好字段就能正常工作。 少了几个「不起眼」的部分会直接导致功能失效:没有is_active字段——Django 的ModelBackend在认证时会调用user_can_authenticate()检查它,缺失会导致认证异常或永远失败;没有is_staff——无法登录 admin;没有继承PermissionsMixin——没有is_superuser/groups/user_permissions和has_perm(),权限系统和 admin 都用不了;没有自定义 Manager——createsuperuser命令和很多第三方包依赖objects.create_user()/create_superuser();Manager 没写use_in_migrations = True——数据迁移里调用create_user会失败。写完之后跑一次python manage.py check是最快的验证方式,Django 有一整套auth.E001~E00x的系统检查(比如USERNAME_FIELD指定的字段没加unique会报auth.E003、REQUIRED_FIELDS里包含USERNAME_FIELD会报auth.E002)。 - 追问:
AbstractUser和AbstractBaseUser到底怎么选? 判断标准是**「你要不要改用户模型的骨架」。选AbstractUser的场景(占绝大多数):只是要加字段**(手机号、头像、租户、实名信息),或者只是想让email唯一、想改 username 的校验规则——它自带 username、email、姓名、密码、is_staff/is_active/is_superuser、权限关联和全套方法,你继承后加几个字段就完事,admin 和各种表单也能直接复用。选AbstractBaseUser的场景:完全不需要 username 字段(纯邮箱或手机号体系,留着一个空的 username 既占空间又要处理唯一性)、字段结构差异很大(比如多租户下要求「同一租户内邮箱唯一」而不是全局唯一,这需要重新设计唯一约束)、或者不想要 Django 的权限系统(不继承PermissionsMixin,自己实现一套 RBAC)。一个折中技巧:用AbstractUser但设username = None+USERNAME_FIELD = "email"+ 自定义 Manager,也能达到「用邮箱登录且没有 username」的效果,改动比完全自己写少。 - 追问:只是想让用户用邮箱或手机号登录,一定要改用户模型吗? 不一定——如果只是「登录时能用邮箱/手机号作为账号输入」,写一个自定义认证后端就够了,完全不用动模型。做法是继承
ModelBackend重写authenticate():用Q(email=username) | Q(phone=username) | Q(username=username)去查用户,然后check_password()校验并调用self.user_can_authenticate(user)(这一步会检查is_active),最后把它加进AUTHENTICATION_BACKENDS(Django 会按顺序依次尝试各个后端)。要注意三点:邮箱/手机号字段必须建唯一约束(否则get()会抛MultipleObjectsReturned,也存在账号劫持风险)、要处理大小写(邮箱统一转小写存储和查询)、要防用户枚举(用户不存在时也要做一次假的密码哈希运算,避免通过响应时间判断账号是否存在)。这个方案对已上线且无法更换AUTH_USER_MODEL的项目尤其有价值。 - 追问:一对一 Profile 和把字段直接放进 User,实践中怎么权衡? 核心权衡是**「访问频率 vs 表宽度」。放进 User 的:每个请求(或大多数页面)都要用到的字段——登录标识、状态、角色、租户 ID、显示名——因为它们随
request.user一起加载,不需要额外查询。放进 Profile 的:只在特定场景用到的大字段或低频字段——实名认证材料、详细地址、偏好设置、第三方账号绑定信息——把它们放进主表会让每次加载用户都传输无用数据**(尤其request.user在中间件里几乎每个请求都会查一次)。代价是每次访问 Profile 都要多一次查询,列表页必须select_related("profile")否则就是 N+1。所以实践中常见的是混合方案:核心字段进 User、档案类字段进 Profile。另外要注意 Profile 的创建时机——用post_save信号自动创建有坑(bulk_create不触发信号会导致 Profile 缺失),更稳的是惰性get_or_create或在业务的注册流程里显式创建。
八、加强记忆
Django 官方强烈建议新项目第一天就设置自定义用户模型,哪怕它是空的 class User(AbstractUser): pass——因为项目中期更换 AUTH_USER_MODEL 官方明确说「不受支持且非常困难」:所有指向 auth_user 的外键(你自己的 + admin_log + 权限关联表 + 第三方包)都要重建、数据要保持 id 不变地迁移、content_type 和权限表也要修复;成本从「3 行代码」变成「数天到数周 + 停机窗口」。三种方案:AbstractUser(默认选择)——继承完整用户模型只加字段;AbstractBaseUser——只有 password + last_login,适合「完全不要 username、用邮箱/手机号登录」,必须自己写 Manager(create_user 里用 set_password、加 use_in_migrations = True)、自己加 is_active(不加会导致认证永远失败)和 is_staff(不加登不了 admin)、并配 PermissionsMixin 才有权限系统;一对一 Profile——已上线项目的唯一现实选择,但改不了登录字段、每次访问都要 JOIN(记得 select_related 防 N+1)。三个类属性:USERNAME_FIELD 是登录字段(必须 unique)、REQUIRED_FIELDS 是 createsuperuser 额外询问的(不能包含 USERNAME_FIELD)、EMAIL_FIELD 用于密码重置。引用用户模型的规则不能混:模型外键用 settings.AUTH_USER_MODEL 字符串(延迟解析、避免循环导入、迁移能记录依赖)、运行时代码用 get_user_model()(别在模块顶层调用)、绝不能 from django.contrib.auth.models import User(换模型后仍指向 auth.User,症状是查空表、关联错表、isinstance 恒假)。其他易错点:密码必须用 set_password 不能直接赋值(否则存明文、永远登不上,bulk_create 也不会帮你哈希;改密码后记得 update_session_auth_hash)、request.user 未登录时是 AnonymousUser 而它的真值是 True(判断要用 is_authenticated,且它是属性不是方法)、自定义后 admin 要在 BaseUserAdmin.fieldsets 基础上追加否则新字段不显示。已上线还想换?用 db_table = "auth_user" 复用原表避免数据迁移,或者干脆写一个自定义认证后端实现邮箱/手机号登录(不改模型,很多场景下够用)。