Django全栈项目自定义用户模型PeekpaUser设计与落地实践

发布时间:2026/10/13 23:46:53
Django全栈项目自定义用户模型PeekpaUser设计与落地实践
做到第十期终于要动用户这块了。PeekpaUser这个名字听起来像是某个模块的代号其实就是在Django全栈项目里自定义用户模型。很多初学者用默认的User表用得好好的等业务里突然要加手机号、昵称、头像甚至要支持手机号登录时才发现默认模型根本不够用后面再改就是动筋骨的事。这篇文章分享一下我设计和落地PeekpaUser的完整过程从模型定义、认证后端、序列化接口到迁移和测试一次把关键点讲透。1. 为什么自定义用户模型是迟早的事1.1 默认User模型的天花板Django自带的auth.User用起来确实方便注册、登录、Admin认证全都能直接跑。但它的字段固定为用户名、邮箱、密码、姓名、分组和权限这些字段在日常业务里明显不够。我见过很多项目早期表结构里直接塞一个Profile表通过OneToOne关联到User后期每次查询都要额外join一次逻辑一多就乱。更麻烦的是登录方式。默认登录只认用户名加密码如果你想支持手机号或邮箱登录就得自己写后端逻辑。默认User模型里并没有手机号字段也没有统一的第三方登录标识。这些需求只要一出现默认User就成了拖后腿的存在。模块设计这事越晚动手代价越大。项目的model一旦有了外键指向默认User后续再换自定义用户模型数据库迁移会非常痛苦。所以只要团队能确定这个项目“以后一定会扩展用户字段”我就强烈建议在项目初期就定义好自己的用户模型哪怕第一版只多加一两个字段。1.2 自定义模型用继承还是组合当决定要“自己造用户”之后通常会遇到两个选择继承AbstractUser或者用OneToOne关联一个Profile表。继承AbstractUser等于把默认User的所有字段和能力都拿过来再加自己的字段。这样改造成本最低原有代码不用动Admin、权限框架、认证后端全部保留只是让Django的User变成自己定义的PeekpaUser。组合方案则是用户基础信息留在默认User里扩展信息放在另一张表通过一对一关联。在数据库层面组合方式更“干净”但业务代码中每次获取用户资料都要多一次查询而且要处理创建Profile的时机容易漏同步。PeekpaUser我选的是继承AbstractUser。原因很简单第一快速上线优先单表结构好维护第二Django的权限系统直接构建在User上继承可以让很多现成机制无缝工作第三以后如果真要拆表再迁移也来得及。如果项目里普通用户和管理员字段差异极大那才值得考虑多表继承或Composition模式普通场景下AbstractUser是性价比最高的选择。2. PeekpaUser的字段与模型设计2.1 先梳理业务需求再写代码写模型之前我先在纸上把用户体系要覆盖的场景列了一下。这个模块主要是为Web端和移动端提供统一的账号能力最基础的需求包括注册时支持邮箱或者手机号手机号后面要支持验证码登录用户能修改昵称、头像、个人简介账号有状态字段方便封禁、停用操作需要记录用户创建时间、最后登录时间部分字段不是所有用户都填写比如生日、性别、所在地。这些需求直接决定字段写法和null、blank参数。不要上来就把所有字段统统加一遍很多字段未来用不用其实是未知数。我第一版里加了一个“QQ号”字段后面两年都没人用最后删掉还倒腾了一场迁移这就是乱设计字段的教训。2.2 核心模型代码实现在users/models.py里最核心的代码大致是这样from django.contrib.auth.models import AbstractUser from django.db import models class UserManager(BaseUserManager): use_in_migrations True def create_user(self, username, emailNone, passwordNone, **extra_fields): if not username: raise ValueError(用户名不能为空) email self.normalize_email(email) user self.model(usernameusername, emailemail, **extra_fields) user.set_password(password) user.save(usingself._db) return user def create_superuser(self, username, emailNone, passwordNone, **extra_fields): extra_fields.setdefault(is_staff, True) extra_fields.setdefault(is_superuser, True) extra_fields.setdefault(is_active, True) return self.create_user(username, emailemail, passwordpassword, **extra_fields) class PeekpaUser(AbstractUser): mobile models.CharField(max_length20, uniqueTrue, nullTrue, blankTrue, verbose_name手机号) nickname models.CharField(max_length30, blankTrue, verbose_name昵称) avatar models.ImageField(upload_toavatars/, blankTrue, verbose_name头像) bio models.TextField(max_length500, blankTrue, verbose_name个人简介) gender models.CharField(max_length10, choices((male, 男), (female, 女), (secret, 保密)), defaultsecret, verbose_name性别) birthday models.DateField(nullTrue, blankTrue, verbose_name生日) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) updated_at models.DateTimeField(auto_nowTrue, verbose_name更新时间) objects UserManager() class Meta: verbose_name 用户 verbose_name_plural 用户这段代码里有几个关键点必须解释清楚。第一USERNAME_FIELD默认是username我在这里没改因为项目里仍然保留用户名注册通道。如果你希望完全用手机号登录那需要把USERNAME_FIELD改成mobile同时要定义REQUIRED_FIELDS。改掉它会影响createsuperuser的提示交互要做好心理准备。第二mobile设置成uniqueTrue但nullTrue因为不是所有用户都填手机号。很多新手在这里会纠结unique和null能不能同时用在Django里是可以的因为多个NULL值不算重复数据库层面不会冲突。但要注意如果用空字符串多用户空串会被当成重复值所以最好用None表示未填写。第三avatar用ImageField后要记得安装Pillow否则迁移时报错。在settings里也要配置MEDIA_ROOT和MEDIA_URL否则上传的文件没有落脚点。2.3 字段选择的常见坑关于blank和null很多人分不清。blank是表单层面是否必填null是数据库层面是否允许为NULL。文本类字段比如昵称、简介用blankTrue就够因为Django的CharField存空串更合理。时间字段和数值字段要看情况日期字段用nullTrue因为空日期没有“空字符串”的替代方案。Bool字段的默认值也要注意。如果带权限的字段没设默认值老数据迁移时会要求你提供默认值有时候还会手动让你选。开头的is_active默认是True但如果你改了AbstractUser的一些字段记得保留默认值行为一致。还有一点是字段命名。mobile、nickname这种英文没问题但尽量别用phone_number这种超级长的名字写到代码里不直观。再有就是不要用name、type这种太通用的字段名容易和Django内部逻辑冲突。3. 认证流程与自定义管理器3.1 自定义认证后端支持手机号登录Django原生的认证后端只支持用户名加密码。我实现PeekpaUser后希望用户能通过手机号或邮箱登录这就需要自定义一个认证后端。在users/backends.py里写一个类from django.contrib.auth.backends import ModelBackend from django.db.models import Q from .models import PeekpaUser class PeekpaBackend(ModelBackend): def authenticate(self, request, usernameNone, passwordNone, **kwargs): # 支持通过用户名、手机号、邮箱三种方式查找用户 if username is None: username kwargs.get(PeekpaUser.USERNAME_FIELD) if username is None or password is None: return None try: user PeekpaUser.objects.get( Q(usernameusername) | Q(mobileusername) | Q(emailusername) ) except PeekpaUser.DoesNotExist: return None if user.check_password(password) and self.user_can_authenticate(user): return user return None这里需要用Q组合条件避免三条过滤分开查。不要用get_or_create去自动创建用户登录动作不该产生副作用。self.user_can_authenticate是内置方法用来检查用户是否激活避免把封禁账号放进来。然后在settings.py里切换到自己的后端AUTH_USER_MODEL users.PeekpaUser AUTHENTICATION_BACKENDS [ users.backends.PeekpaBackend, ]这里有个容易踩的坑如果之前已经设置了AUTH_USER_MODEL又去INSTALLED_APPS里加了一个新的app顺序很重要。AUTH_USER_MODEL引用的模型对应的app必须已经在INSTALLED_APPS里且在第一次makemigrations之前设置好。3.2 用户管理器的实现逻辑自定义用户模型的时候官方文档要求同时定义UserManager。上面代码里的create_user和create_superuser是最基础的实现。create_superuser里必须设置is_staff和is_superuser为True否则命令行的createsuperuser建出来的账号无法进入Admin。有两点要注意email要用self.normalize_email做一次标准化把大小写统一减少后面查询时出偏差的概率set_password必须调用不能直接把明文密码存进去。很多人写创建脚本时图省事设了密文字段就完事结果登录一直失败。我在实际项目里还会在Manager里加一个get_by_natural_key因为它被ModelBackend调用如果你改了USERNAME_FIELD这个方法的默认逻辑也要跟着走。不过PeekpaUser没改USERNAME_FIELD所以默认行为就没问题。3.3 登录接口的处理思路有了自定义认证后端接着要把它和接口层串起来。如果是传统session登录直接在视图里调用django.contrib.auth.authenticate和login就能工作。如果用的是DRF我更推荐配合rest_auth或者自己写一个登录视图用token方案。我的处理方式是用DRF的ObtainAuthToken思路from rest_framework.authtoken.models import Token from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status class LoginView(APIView): authentication_classes [] permission_classes [] def post(self, request): from django.contrib.auth import authenticate username request.data.get(username) password request.data.get(password) user authenticate(request, usernameusername, passwordpassword) if user: token, _ Token.objects.get_or_create(useruser) return Response({token: token.key, user_id: user.id}) return Response({error: 用户名或密码错误}, statusstatus.HTTP_401_UNAUTHORIZED)注意这里的authentication_classes和permission_classes必须清空否则登录接口会被全局认证策略挡住。这个坑特别常见很多同学改了认证后端还是返回401一看是默认认证类挡在视图前。如果没有引入rest_framework.authtoken要去INSTALLED_APPS里加上rest_framework.authtoken并且执行迁移。token表结构很简单但它的安全级别上限就是“存活期一起续”如果项目对安全性要求高建议后面换JWT。4. 序列化与API视图设计4.1 注册接口用序列化器处理密码加密写注册接口时最容易犯的错误是直接把User对象的数据一股脑往里塞。PeekpaUser有一个特殊的地方avatar是文件字段注册阶段通常不传所以序列化器里要允许为空。我的users/serializers.py里这样写的from rest_framework import serializers from .models import PeekpaUser class UserRegisterSerializer(serializers.ModelSerializer): password serializers.CharField(write_onlyTrue, min_length8, style{input_type: password}) confirm_password serializers.CharField(write_onlyTrue, style{input_type: password}) class Meta: model PeekpaUser fields [username, mobile, email, password, confirm_password] extra_kwargs { mobile: {required: False}, email: {required: False}, } def validate(self, attrs): if attrs[password] ! attrs[confirm_password]: raise serializers.ValidationError({confirm_password: 两次密码不一致}) return attrs def create(self, validated_data): validated_data.pop(confirm_password) password validated_data.pop(password) user PeekpaUser(**validated_data) user.set_password(password) user.save() return userwrite_onlyTrue是为了保证密码字段只进不出避免后面查询用户详情时把密码哈希一起返回给前端。confirm_password不应该进入数据库所以我只在create里把它pop掉。很多新手会在create时直接PeekpaUser.objects.create(password...)这是致命的密码会被当字符串存进去登录时永远校验失败。正确做法一定是set_password。4.2 用户详情与资料更新的逻辑用户详情接口我习惯只返回需要的字段避免整个大对象直接抛给前端。单独写一个UserProfileSerializerclass UserProfileSerializer(serializers.ModelSerializer): class Meta: model PeekpaUser fields [id, username, mobile, email, nickname, avatar, bio, gender, birthday, created_at]更新逻辑要处理部分更新让前端只提交修改的字段。视图层用serializer.partialTrue配合PATCH请求就行。有一点容易忽略如果允许用户改手机号那么手机号更新时最好做一次唯一性校验同时更新后要重新验证手机号防止改错。序列化器里加上validate_mobile是最干净的方式。4.3 权限控制与对象级权限PeekpaUser接口的权限至少要区分三类情况不登录也能访问注册、登录、找回密码登录用户可访问查看和修改自己的资料管理员可访问封禁、改角色、查看所有用户列表。在DRF里最简单的方式是按照view设置权限类。查看和修改自己的资料我写一个对象级权限from rest_framework.permissions import BasePermission class IsSelfOrAdmin(BasePermission): def has_object_permission(self, request, view, obj): if request.user.is_staff: return True return obj.id request.user.id然后在DetailView里调用self.check_object_permissions(request, user_obj)这样就能保证用户只能操作自己的资料管理员能操作所有人。权限逻辑放在视图里属于最底层业务层可以通过signal或者service再补一层但至少接口层不能裸奔。5. Admin后台与数据迁移的落地细节5.1 自定义Admin注册自定义用户模型之后Admin如果不注册那么后台用户管理页面直接消失。需要在users/admin.py里写from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import PeekpaUser admin.register(PeekpaUser) class PeekpaUserAdmin(UserAdmin): list_display [id, username, mobile, email, is_staff, is_active, created_at] search_fields [username, mobile, email] ordering [-created_at] fieldsets UserAdmin.fieldsets ( (扩展信息, {fields: (mobile, nickname, avatar, bio, gender, birthday)}), )继承UserAdmin非常重要只有继承它才能保留密码重置、权限分配这些后台能力。如果直接用admin.ModelAdmin你会看到一个没法改密码的用户列表切到修改页会发现权限字段全是坏的。fieldsets扩展的时候注意UserAdmin.fieldsets这个元组不能拼接错网上很多教程把()和写漏了结果Admin页面直接报错。5.2 换用户模型后的迁移顺序这个坑我至少听人提过一百遍。假如项目里已经建了若干个业务表并且这些表通过外键指向了默认User此时想换到PeekpaUser不是改个settings就能完事的。正确的做法是在项目一开始就设置好AUTH_USER_MODEL users.PeekpaUser然后第一次makemigrations时users app会先生成PeekpaUser的迁移文件其他表再生成。这样外键就会指向自定义用户模型。如果已经晚了项目跑过几版了那么数据迁移的复杂度直线上升。最痛苦的是外键字段在数据库层面已经指向auth_user表你不可能直接改表名就让Django认。这时候要做的是先创建迁移把自定义用户模型建出来再写数据迁移把auth_user的数据复制到users_peekpauser然后调整外键指向最后删除旧的默认User表。整个过程里任何一步锁表都会影响线上环境。所以我的经验是宁可第一版模型设计多花一天也别等到生产了再换。5.3 部署前必须检查的配置部署相关的问题即使改完模型测试全过也还是会冒出来。列几个我每次都要检查的点settings.AUTH_USER_MODEL和AUTHENTICATION_BACKENDS是不是一起配置好了MEDIA_ROOT、MEDIA_URL是否正当头像上传接口是否允许外网访问DEFAULT_AUTO_FIELD设置是否和迁移文件一致数据库连接账号有没有迁移权限如果用了rest_framework.authtokentoken表有没有生成。另外一个容易漏的如果部署环境用了CDN或反向代理上传头像时文件大小的限制要提前在Nginx或网关层配好否则用户传个大图请求超时后Django日志里什么都看不出来。6. 常见坑与排查经验6.1 外键关联User时报错如果你在别的app里写了models.ForeignKey(User, ...)但User没有显式导入get_user_model()导致模型定义使用的是官方auth.User在自定义用户模型项目里会直接报ValueError或迁移不一致。正确写法是from django.contrib.auth import get_user_model User get_user_model()get_user_model()会读取AUTH_USER_MODEL设置返回自定义模型。如果你非要用from django.contrib.auth.models import User那就等于写死了默认模型系统直接偏航。6.2 makemigrations报错的三种常见原因具体表现多种多样但常见集中在三种第一AUTH_USER_MODEL引用的app还没加入INSTALLED_APPS导致找不到这个模型。这种情况报错会包含cannot import name或related model ... not installed。第二迁移历史有冲突。特别是之前用过默认User中途改成自定义模型又没有清理老迁移Django会告诉你“无法解析外键依赖”。解决策略是备份好数据重置整个迁移链开发环境可行生产环境要小心。第三Pillow没安装ImageField被解析时报错。这个很简单直接pip install Pillow并且确保在环境里能用import PIL。6.3 登录接口400/401排查清单登录接口如果总是返回400或401我一般按照这个顺序排查后端有没有被错误地设置为空权限登录视图不能带上全局认证类自定义后端里查询条件有没有写错用手机号登录时手机号前后有没有空格密码是不是明文存储直接在数据库里看一下hash字符串长什么样用户是不是is_activeFalse被封禁用户会通过后端的user_can_authenticate被排除请求体字段名是否和视图解析的字段名一致。前端传mobile后端读username必然失败。这五步走完90%的登录问题都能定位。6.4 性能与安全层面的设计建议PeekpaUser虽然只是一个用户模型但它关系到整个系统的认证安全。我最后补几条个人习惯不在日志里输出密码和token用户密码统一走set_password禁止mark-dirty式写入对手机号、邮箱做唯一索引防止并发注册时产生重复账号视图中查询用户列表一定要分页不要一次性返回全量数据后面如果做第三方登录扩展字段时优先加oauth_provider和openid不要把第三方凭据全堆在用户表里。安全这块至少要把常见漏洞面关掉。比如注册接口里如果有异常不向客户端返回具体SQL错误信息统一转换成“参数错误”。这些细节看似不起眼在真实攻击面前都是漏洞入口。7. 测试用例怎么覆盖用户模块7.1 核心测试要点写测试的时候很多人觉得用户模块简单几行用例就完事。实际上用户相关逻辑是安全敏感部分值得重点测。我认为最少要覆盖这些场景注册成功密码被哈希不返回明文注册失败两次密码不一致、手机号重复、用户名重复登录成功用户名登录、手机号登录、邮箱登录登录失败密码错误、用户被禁、用户不存在用户详情能读自己的资料、不能读别人的资料管理员操作能查看所有用户、能禁言用户更新资料部分更新、非法字段被忽略。每一类都对应一条或多条测试用例跑不过去的地方通常就是序列化器或者权限类写错了。7.2 一个最小但完整的测试示例以Django的TestCase为例基本的注册登录核心流程可以这样写from django.test import TestCase from django.urls import reverse from .models import PeekpaUser class PeekpaUserApiTest(TestCase): def test_register_and_token(self): data { username: alice, mobile: 13800000000, email: aliceexample.com, password: testpass123, confirm_password: testpass123, } resp self.client.post(reverse(user-register), data, content_typeapplication/json) self.assertEqual(resp.status_code, 200) user PeekpaUser.objects.get(usernamealice) self.assertTrue(user.check_password(testpass123)) def test_login_by_mobile(self): # 先创建用户 PeekpaUser.objects.create_user(usernamealice, passwordtestpass123, mobile13800000000) resp self.client.post(reverse(user-login), {username: 13800000000, password: testpass123}, content_typeapplication/json) self.assertEqual(resp.status_code, 200) self.assertIn(token, resp.json())这里注意使用content_typeapplication/jsonDRF的JSON解析器才不会把payload当成表单。如果用默认的client.post不带content_type有时候会得到空数据。7.3 我实际踩过的测试坑最难忘的一次是测试登录接口文件里所有用例都是绿的一到部署现场就404。查了一阵子发现是URL路由在测试环境里注册了一版实际部署环境里因为静态文件配置覆盖了接口路由导致登录路径被静态文件路由抢走了。后来我把URL检查脚本直接挂到部署流水线里确保关键路径都能返回预期状态码。另一个坑是测试数据库没有生成token表。如果在INSTALLED_APPS里面配置了rest_framework.authtoken测试数据库本来会自动创建相关表但如果有人手动调整过迁移顺序token表就可能缺位。排查方式就是在测试里直接调用Token.objects.get_or_create看会不会抛表不存在的异常。我的经验是把测试当成项目的一部分而不是发布前敷衍一下。用户模块的每个权限分支都值得写一条测试哪怕多花半小时后面重构的时候能少掉很多头发。PeekpaUser这套设计目前在我这边已经跑了好几个完整的Django项目从早期单表用户模型到后来增加手机号登录、头像上传已经比较稳。整体思路就是“先继承AbstractUser再慢慢加字段”不要一上来就搞复杂的微服务认证。先把一个用户模块做得扎实后面的全站权限、第三方登录、多端会话管理都有了一个可靠的锚点。