← 返回题目列表

Django 为什么建议一开始就自定义 User 模型?怎么做?

中等 第 18 / 27 题 更新于 2026/08/01
Django自定义用户模型AUTH_USER_MODELAbstractUser

简化版

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 之前设置好。一旦执行过迁移,authadminsessions、以及你自己所有指向用户的外键都已经在数据库里建立,之后再改这个设置,Django 会要求你重建大量外键、迁移数据、处理权限表——官方文档明确说明「更改 AUTH_USER_MODEL 在项目中期是不受支持的,会带来严重困难」。所以哪怕现在完全不需要额外字段,也要先建一个空的 class User(AbstractUser): pass,成本几乎为零,却省掉了未来可能几天的痛苦。② 绝不能直接给 password 字段赋值user.password = "123456" 存进去的是明文,登录时永远验证失败(因为 Django 会用哈希算法比对)。正确做法是 user.set_password(raw)(内部用 PBKDF2 等算法加盐哈希),验证用 user.check_password(raw);批量创建用户时也要逐个 set_passwordbulk_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/UserChangeFormMeta.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 只提供 passwordlast_login 加上密码相关方法,其余全部自己定义——适合「完全不要 username」或字段结构差异很大的场景。实现有四个必需组件:自定义 Managercreate_user/create_superuser必须用 set_password,加 use_in_migrations = True 让数据迁移能用它)、模型定义(登录字段必须 unique,别忘了 is_activeis_staff)、三个类属性USERNAME_FIELD 是登录字段、REQUIRED_FIELDScreatesuperuser 额外询问的字段且不能包含前者、EMAIL_FIELD 用于密码重置)、以及 PermissionsMixin(提供 is_superuser/groups/user_permissionshas_perm,不继承就等于放弃 Django 的权限系统和 admin)。常见的坑:忘了 is_active 会导致认证永远失败(认证流程会检查它)、忘了 is_staff 无法登录 adminManager 里直接赋值 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 时它是唯一现实选择,另外「扩展字段属于另一个领域」(员工档案、商家资质)或「字段很多但少用」时它也合理。但两个硬限制要清楚:改不了登录字段每次访问扩展字段都要多一次 JOINfor 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_typeauth_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) 永远返回 FalseUser.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_authenticatedFalseis_anonymousTruehas_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_permissionshas_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.E003REQUIRED_FIELDS 里包含 USERNAME_FIELD 会报 auth.E002)。
  • 追问:AbstractUserAbstractBaseUser 到底怎么选? 判断标准是**「你要不要改用户模型的骨架」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、用邮箱/手机号登录」,必须自己写 Managercreate_user 里用 set_password、加 use_in_migrations = True)、自己加 is_active(不加会导致认证永远失败)和 is_staff(不加登不了 admin)、并配 PermissionsMixin 才有权限系统;一对一 Profile——已上线项目的唯一现实选择,但改不了登录字段、每次访问都要 JOIN(记得 select_related 防 N+1)。三个类属性:USERNAME_FIELD 是登录字段(必须 unique)、REQUIRED_FIELDScreatesuperuser 额外询问的(不能包含 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" 复用原表避免数据迁移,或者干脆写一个自定义认证后端实现邮箱/手机号登录(不改模型,很多场景下够用)。