写这篇分享的思路,得从一个很实际的角度说起:做Django全栈项目时,迟早要面对用户系统。用默认的User凑合一阵子没问题,但业务稍微复杂点——比如要存头像、要支持邮箱登录、要区分不同角色——默认模型就束手束脚了。这篇是系列的第10篇,主题是PeekpaUser的设计,本质上就是“在Django里设计一个真正能用的自定义用户模型”。我自己在做某内容平台的后端时,把这块完整踩了一遍,从模型定义、Manager编写、迁移坑、认证接口到前端联动,都有实战经验可以聊。这篇文章适合正在做Django全栈项目、想让用户系统更贴合业务的朋友参考,哪怕你是刚学Django不久,按着步骤走也能把自定义用户跑起来。
1. 动手前先想清楚:PeekpaUser的设计目标与方案取舍
1.1 默认User模型为什么不够用
Django自带的User模型设计得很扎实,该有的字段基本都有:username、password、email、first_name、last_name、is_active、is_staff、is_superuser,配合session认证跑一个后台管理系统绰绰有余。但真正做业务系统时,特别是“全栈”项目,你很快会发现几个绕不开的痛点。
第一个痛点是字段不够用。产品说用户要显示昵称、要上传头像、要填写个人简介、要记录注册来源、要标记性别。这些字段往哪放?很多人第一时间想到再建一个Profile表,用OneToOneField关联过去。方案可行,但每次取用户信息都要跨表查询,序列化的时候要多写一层嵌套,admin后台里也得单独注册。用起来总感觉别扭,就像是给一个不合身的衣服硬缝了两个口袋。
第二个痛点是认证方式死板。Django默认的认证标识是username,很多现代产品更希望直接用邮箱或手机号登录。你当然可以改造认证后端,但默认User模型里username字段是必填且唯一的,想要彻底去掉它、改成纯邮箱登录,就必须动模型本身。
第三个痛点,也是最致命的:Django官方明确建议,如果你确定要用自定义User模型,最好在第一次迁移之前就搞定。项目上线后再改,数据库迁移会变成一场噩梦。我见过有项目在中期想把Profile合并进User表,结果牵一发动全身,光迁移脚本就写了三天。所以“用Django全栈开发”这种系列项目,从一开始就设计好PeekpaUser,绝对是对的选择。
1.2 扩展方案选型:Profile关联表与自定义User模型
当默认User不够用,摆在你面前的是两条经典路线,我把它俩摊开对比一下。
路线一:保留默认User,额外建一张Profile表,用OneToOneField关联。优点是改动小、风险低,官方文档也提供了这种做法的示例;缺点是查询多一次JOIN、admin管理分散、业务代码里到处要处理“用户资料可能不存在”的空值,而且后续如果想把某些业务字段升级到账号体系里,迁移成本高。
路线二:从第一行代码开始就定义自定义User模型,继承AbstractUser或AbstractBaseUser,把业务字段直接放进用户表。好处是模型内聚、查询方便、admin统一管理,认证链路完全是自己的;代价是必须在项目初期就定好,一旦跑起来就不能随意切换。
我当时做PeekpaUser时果断选了路线二。原因其实很朴素:这是一个全新项目,没有历史包袱,那为什么不从第一天就把地基打正?另一个考虑是后续想接入JWT、第三方登录、手机验证码登录这类能力时,自定义模型的可扩展性远高于“User+Profile”的双表方案。而且从全栈角度看,前端每次请求用户信息,后端一个接口就能返回完整数据,前端调用的路径短了一大截。
1.3 PeekpaUser的定位:不是技术炫技,而是业务驱动
关于“自定义User模型”这件事,我得说句实在话:它不应该是为了“看起来高级”才做的。如果你只是做个个人博客、内部工具,默认User完全够用,没必要画蛇添足。PeekpaUser在这个系列里之所以值得设计,是因为它的定位就是一个内容平台的用户实体,得承载注册、登录、个人中心、作者展示、后台权限这些完整链路。
所以做设计决策时,我一直坚持一个原则:先列业务需求,再定字段;先定场景,再选认证方式。我梳理这个项目时,用户相关的需求其实就五条:一是用户能通过邮箱注册登录;二是登录后能维护自己的昵称、头像、简介;三是管理员能够通过后台管理所有用户;四是内容展示时要能显示作者信息;五是后续要支持第三方登录,预留扩展位。把这五条记在心里,PeekpaUser的字段和接口就都有了明确出处,而不是拍脑袋堆字段。
2. 数据模型设计:字段规划与核心模型落地
2.1 PeekpaUser的字段清单与设计理由
先亮出我的核心模型代码,再说为什么每个字段都存在。
from django.contrib.auth.models import AbstractUser, BaseUserManager from django.db import models class ProfileManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError("邮箱是必填项") email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) if extra_fields.get("is_staff") is not True: raise ValueError("超级用户必须设置is_staff=True") if extra_fields.get("is_superuser") is not True: raise ValueError("超级用户必须设置is_superuser=True") return self.create_user(email, password, **extra_fields) class PeekpaUser(AbstractUser): username = None email = models.EmailField("邮箱", unique=True) nickname = models.CharField("昵称", max_length=32, blank=True) avatar = models.ImageField("头像", upload_to="avatars/", blank=True, null=True) bio = models.TextField("个人简介", blank=True, default="") gender = models.CharField("性别", max_length=8, choices=( ("male", "男"), ("female", "女"), ("secret", "保密"), ), default="secret") source = models.CharField("注册来源", max_length=20, default="site") created_at = models.DateTimeField("创建时间", auto_now_add=True) updated_at = models.DateTimeField("更新时间", auto_now=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = [] objects = ProfileManager() def __str__(self): return self.email class Meta: db_table = "peekpa_user" verbose_name = "用户" verbose_name_plural = "用户"字段设计里有几个关键决策点,值得展开讲。
干掉username,用email做唯一登录标识。这是现代Web产品的常见做法。用户不需要记住一个额外用户名,邮箱天然全局唯一,也能省去“用户名已被占用”这种交互。注意我在PeekpaUser里把username = None,然后设置USERNAME_FIELD = "email"。这是自定义用户模型里最核心的一个操作:告诉Django“以后别拿用户名来认证了,用邮箱”。REQUIRED_FIELDS = []表示用createsuperuser命令创建超管时,只需要交互式输入email和密码,不需要额外填字段。如果你加了其他必填字段,得在这里列出来。
昵称、头像、简介、性别、来源字段为什么保留。nickname是业务展示名,默认空字符串,用户注册后可以自己改;avatar用ImageField,配合Django的MEDIA配置,前端可以直接通过URL拿到头像;bio是个人简介,给文章作者展示用;gender用choices限定选项,后台下拉框和前端选择器都好做;source是我想重点提一下的字段,虽然它不入库也能查,但记录用户从哪注册(站内注册、第三方、邀请链接)对产品后期分析留存、做渠道评估非常重要,一个字段就能省掉埋点的成本,很划算。
为什么不直接继承AbstractUser而是自己写Manager。项目里我继承了AbstractUser,保留了Django内置User的权限体系和admin支持,省掉大量重复代码。但既然把登录标识换成了email,默认的UserManager(它绑定username)就不能用了,所以必须自定义ProfileManager,重写create_user和create_superuser。这是自定义User模型里最容易忽略、却最致命的一步——如果不重写,用manage.py createsuperuser创建管理员时会报错。
2.2 自定义UserManager的落实:创建用户与超管
上面的代码里Manager已经给出了,我再加点说明。create_user的核心动作有三个:校验email非空、normalize_email格式化大小写、set_password做密码哈希。注意压轴的一步是user.save(using=self._db),self._db来自BaseUserManager,确保写入的是当前数据库路由指向的那个库。这些细节都很小,但少了任何一步,创建用户时都可能出问题。
create_superuser比create_user多了两步:强制is_staff和is_superuser为True,然后做两个if校验。为什么要手动校验这几行?因为这是Django的安全底线。如果不校验,万一哪天代码里误传了is_superuser=True以外的值,你可能会生成一个看起来是超管、实际权限不完整的畸形账号。多写四个条件判断,成本极低,但能给后续维护省掉一个隐蔽的坑。
还有一个小细节:create_superuser里我用set_password(password)处理明文密码,这个函数会把明文自动哈希并赋值给password字段。如果你图省事直接user.password = password,那存进去的就是明文的“123456”,登录验证永远通过不了。这个坑我在早期写代码时踩过一次,排查了半天才发现密码没走set_password。
2.3 AUTH_USER_MODEL配置与迁移注意事项
模型写完后,必须在settings.py里告诉Django“从今往后,用户模型就是PeekpaUser”,配置如下:
AUTH_USER_MODEL = "users.PeekpaUser"这里的users是app名,PeekpaUser是模型类名。这一步的时机极其重要:必须在第一次执行makemigrations之前配置好。因为Django在第一次迁移时会生成auth相关的依赖关系,如果默认User模型已经被引用进了迁移文件,再切换自定义模型,迁移依赖就会乱掉。所以如果你是照着这个系列从零开始搭建项目,请务必在创建users这个app之后、第一次生成任何迁移文件之前,先把这行配置写上。
迁移顺序的建议是:先配置AUTH_USER_MODEL,再makemigrations users,再migrate。如果项目已经运行了一段时间,才想换成自定义User模型,那机率不大,方案只有“做数据迁移脚本,把原用户表数据导入新表”,复杂度高得多。这也是我前面反复强调“从第一天就自定义”的原因。
数据库表名方面,db_table = "peekpa_user"是我刻意指定的。默认情况下Django会自动生成users_peekpauser这种带app前缀的表名,指定db_table能让表名更干净、迁移脚本里引用更明确。不过这里有个辩证点:指定表名后,如果你以后重构app名称,表不会跟着变动,需要手动处理。好处是数据库层面表名稳定,与其他团队沟通时不易混淆。我自己倾向于显式指定,因为“peekpa_user”这个名字语义明确。
3. 认证与业务逻辑:注册、登录与权限体系
3.1 注册流程的实现细节
模型是骨架,认证接口才是用户真正会用到的部分。注册流程我拆成了三个环节:接收参数、校验过滤、创建用户。
视图层我用Django REST framework来写,用ModelSerializer管理字段。先看代码:
from rest_framework import serializers from .models import PeekpaUser class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, min_length=8) class Meta: model = PeekpaUser fields = ["email", "password", "nickname"] extra_kwargs = { "email": {"required": True, "allow_blank": False}, "nickname": {"required": False, "allow_blank": True}, } def create(self, validated_data): request = self.context.get("request") source = request.data.get("source", "site") if request else "site" user = PeekpaUser.objects.create_user( email=validated_data["email"].lower(), password=validated_data["password"], nickname=validated_data.get("nickname", ""), source=source, ) return user几个设计点说一下。
password = serializers.CharField(write_only=True, min_length=8),write_only=True格外重要。它保证密码只会从客户端传到服务端,而在任何输出响应、序列化成JSON的时候都不会被带出去。密码安全问题不能靠侥幸,序列化器层面就该堵死。min_length=8是前端之外的第二道防线,前端程序员可能会忘记做校验,后端这里必须兜底。
create方法里我调用了ProfileManager.create_user,这样密码会走set_password哈希,逻辑保持单一入口。注册后返回的user对象会经过serializer重新序列化,但因为password是write_only,响应里只有email和nickname,不泄露密码。另外我把email强制lower()了一下,统一存储格式,避免“A@test.com”和“a@test.com”被当成两个账号。这个处理在邮箱唯一约束下能避免很多边界问题。
注册视图可以是APIView,也可以直接复用框架的通用视图。我比较推荐使用GenericAPIView配合CreateModelMixin,但如果你想把注册、登录逻辑都集中在一个文件里变清晰方便调试,用APIView手写其实也很清晰,如下:
from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status class RegisterView(APIView): permission_classes = [] # 允许匿名访问 def post(self, request): serializer = RegisterSerializer(data=request.data, context={"request": request}) if serializer.is_valid(): user = serializer.save() return Response({ "code": 0, "data": {"email": user.email, "nickname": user.nickname} }, status=status.HTTP_201_CREATED) return Response({"code": 400, "errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)permission_classes = []表示这个接口允许匿名注册,否则框架默认会要求登录才能调用。新手在写注册接口时经常在这里卡壳,忘了把认证权限放开。
3.2 登录与Token方案的选择
登录是全栈项目里绕不开的环节,方案选择会直接影响前端实现。最常见的两种是Session和JWT。
Session登录跟Django自带admin是同一套机制。用户登录后,服务端session记录ID,前端通过HTTP cookie自动携带。优点是有现成的login、logout函数,CSRF防护框架也内置了,不需要额外依赖;缺点是原生对前端跨域不友好,如果是前后端分离、接口部署在不同域名下,配CORS和CSRF会比较折腾。
JWT方案则是无状态的,登录后服务端签发一个带签名的Token,前端在每次请求的Header里带上Authorization: Bearer <token>。Django REST framework配一个SimpleJWT就能搞定,不涉及session存储,非常适合API化、移动端、多端共用的场景。
我当时在这个项目里选择了JWT。原因是这一系列叫“全栈开发”,前端独立为一个应用,部署环境很可能是纯静态页面+接口服务,Session在这套架构里需要多考虑跨域和CSRF问题,JWT直接避开这些痛点。但我也要说,JWT有个经典缺点:服务端没法主动让Token作废。用户改密码后旧Token可能还有效,处理办法要么是把Token有效期设短,要么加一个“Token版本号”字段存到用户表里校验。我在设计时选择引入一个token_version字段,每次修改密码或调用“踢人下线”接口时自增,带进来的Token如果版本落后就判定失效。代码很短,但能解决JWT的最大软肋。
JWT的接入代码我做了简化处理,使用SimpleJWT自带的TokenObtainPairView,然后自定义一个返回数据结构:
from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.serializers import TokenObtainPairSerializer class LoginSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) data["user"] = { "email": self.user.email, "nickname": self.user.nickname, "avatar": self.user.avatar.url if self.user.avatar else "", } return data class LoginView(TokenObtainPairView): serializer_class = LoginSerializer前端拿到access和refresh两个Token后,把access存到内存或localStorage,请求时放到Authorization头。这里有个安全上的小建议:localStorage容易被XSS脚本读取,敏感场景下优先放内存变量,配合页面刷新时用refresh Token重新换取access。不同项目安全等级不同,我常用的折中方案是access放内存,refresh放HttpOnly cookie。这里限于篇幅不展开,但提醒一句比踩坑后后悔要好。
3.3 权限控制与后台整合
用户系统的权限设计,我把它拆成了两层。第一层是Django自带的is_staff、is_superuser,控制谁能进admin后台;第二层是业务权限,比如“发布文章”“删除评论”,这部分用Django的PermissionsMixin自带的权限模型就可以覆盖。自定义User继承AbstractUser后,天然带user_permissions和groups两个字段,可以给单个用户或用户组分配权限。
后台管理方面,自定义User在admin里默认注册会有个小问题:admin表单里password字段的修改界面显示为空白。原因是Django内置的UserAdmin是围绕默认User模型写的,直接注册自定义User时部分字段映射不上。我在项目里采用了官方推荐的做法:继承UserAdmin重新注册。
from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import PeekpaUser class PeekpaUserAdmin(UserAdmin): ordering = ["-created_at"] list_display = ["email", "nickname", "is_staff", "is_active", "created_at"] list_filter = ["is_staff", "is_active", "source"] search_fields = ["email", "nickname"] fieldsets = UserAdmin.fieldsets + ( ("扩展信息", {"fields": ("nickname", "avatar", "bio", "gender", "source", "token_version")}), ) add_fieldsets = UserAdmin.add_fieldsets + ( ("扩展信息", {"fields": ("nickname", "bio", "gender")}), ) admin.site.register(PeekpaUser, PeekpaUserAdmin)这里还有个容易忽略的地方:因为UserAdmin默认会操作username字段,而我们的模型里没有username,直接继承使用往往会把后台页面搞崩。上面的代码没有显式移除username的处理,是因为fieldsets和add_fieldsets覆盖后username字段其实已经不显示了,但更稳妥的做法是在继承后自定义form时把username = None写进UserAdmin的元信息里。这一点不同Django版本行为有差异,建议测试时先用超管账号在后台创建一个用户看看流程是否顺畅。
4. 全栈配合:接口设计、前端交互与页面联动
4.1 用户接口的标准化设计与调试
全栈项目里,前后端沟通靠的是接口约定。我在设计用户相关的API时,统一响应格式如下:
{ "code": 0, "message": "success", "data": {} }注册、登录、获取用户信息都沿用这套结构。code为0表示成功,非0则表示业务错误,这种约定比直接返回HTTP状态码更直观,前端的拦截器只要判断code即可。路由规划如下:
POST /api/auth/register注册POST /api/auth/login登录GET /api/user/me获取当前用户信息PUT /api/user/me更新昵称、头像、简介POST /api/auth/logout退出登录
/api/user/me在我看来是高频接口,前端几乎每个页面都会拉一下用户状态。实现时用request.user判断当前登录用户,配合JWT的认证类自动解析Token。如果未登录则返回code为401的响应,前端拿到后跳转登录页。
序列化器里我专门整理了一个UserInfoSerializer,输出字段不做全量返回,只返回前端真正要用的那几项。头像、昵称、简介、性别、邮箱。把不必要的东西藏起来,既为了安全,也为了减少网络传输。
class UserInfoSerializer(serializers.ModelSerializer): avatar = serializers.SerializerMethodField() class Meta: model = PeekpaUser fields = ["email", "nickname", "avatar", "bio", "gender", "created_at"] def get_avatar(self, obj): if obj.avatar: return obj.avatar.url return ""4.2 前端页面与文件上传的实现细节
前端部分,我以纯HTML+原生JavaScript为例,演示登录后的用户信息展示逻辑。先放一个简化的注册页面代码,重点不在样式,而在接口联调。
<form id="register-form"> <input type="email" name="email" required placeholder="邮箱"> <input type="password" name="password" minlength="8" required placeholder="密码"> <input type="text" name="nickname" placeholder="昵称"> <button type="submit">注册</button> </form> <script> document.getElementById("register-form").addEventListener("submit", async (e) => { e.preventDefault(); const formData = new FormData(e.target); const payload = JSON.stringify({ email: formData.get("email"), password: formData.get("password"), nickname: formData.get("nickname") }); const resp = await fetch("/api/auth/register", { method: "POST", headers: {"Content-Type": "application/json"}, body: payload }); const data = await resp.json(); if (data.code === 0) { location.href = "/login.html"; } else { console.error(data.errors); } }); </script>这个例子中比较关键的点是Content-Type必须设成application/json,后端用request.data才能正确解析。很多全栈新手第一次联调时,明明后端接口测试通过,前端发送却报400,多半是因为form表单默认以application/x-www-form-urlencoded提交,而后端序列化器在解析上没有兼容。与其在后端做两份兼容,不如前端统一用JSON提交。
头像上传交互时有一些细节。HTML里的<input type="file">默认提交的是二进制文件,但我在注册阶段刻意没有做文件上传,而是放在用户信息更新接口。上传头像时,前端用FormData提交即可。
async function uploadAvatar(file) { const formData = new FormData(); formData.append("avatar", file); const token = localStorage.getItem("access_token"); const resp = await fetch("/api/user/me/", { method: "PUT", headers: {"Authorization": `Bearer ${token}`}, body: formData }); return resp.json(); }请求头只需要带上Authorization,不要手动设置Content-Type。当你把FormData传给fetch时,浏览器会自动生成multipart/form-data边界标记,如果你手动加了Content-Type反而会丢失boundary,后端就无法解析出文件内容。这是我踩过一次的经典前端坑。
后端接收头像的逻辑如下:
class UserProfileView(APIView): permission_classes = [IsAuthenticated] def put(self, request): user = request.user serializer = UserInfoSerializer(user, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response({"code": 0, "data": serializer.data}) return Response({"code": 400, "errors": serializer.errors})这句话就是全栈项目里很经典的一句话:前端FormData直接PUT,后端只要定义avatar字段,Django的ImageField会完成剩余工作。不过要配上settings里的MEDIA_URL和MEDIA_ROOT,否则上传文件后访问不了图片URL。
4.3 前端交互与后端接口的联动方案
用户信息在页面中通常需要全局共享。我在项目里设计了一个轻量级方案:登录后把用户信息存到内存对象里,同时做一个全局的fetch包装函数,统一携带Token、统一拦截401。核心逻辑如下:
async function apiFetch(url, options = {}) { const token = localStorage.getItem("access_token"); const headers = new Headers(options.headers || {}); if (token) { headers.set("Authorization", `Bearer ${token}`); } const response = await fetch(url, {...options, headers}); if (response.status === 401) { localStorage.clear(); location.href = "/login.html"; return null; } return response.json(); }页面加载时用apiFetch("/api/user/me")拿到数据,渲染在导航栏的昵称、头像区域。这个方案虽然简单,但足够支撑中小型项目。等项目变复杂,再上Pinia、Redux那套也来得及,前期不要过度设计。
5. 常见问题与排查经验
5.1 迁移过程中的三个经典坑
如果把自定义User模型的开发过程复盘一遍,迁移阶段的坑绝对能进前三。第一个经典坑是“反向迁移失败”——当你想把User模型改回去删除自定义模型时,Django会提示依赖冲突。这个坑在开发早期还没有真实数据时尤其难察觉,因为你不一定会中途反向迁移。解决思路是开发阶段敢于drop数据库重新来,谨慎起见上线前最后清理一次迁移记录。
第二个坑是执行createsuperuser时报AttributeError: 'ProfileManager' object has no attribute 'create_superuser'。这通常是Manager类写漏了方法,或者没有在模型里用objects = ProfileManager()绑定。排查很直接:看模型里有没有objects属性。
第三个坑是管理后台报FieldError: Unknown field(s) (nickname) specified for PeekpaUser。这个情况往往是你在admin里注册了自定义UserAdmin,但模型字段还没迁移成功。先执行makemigrations确认字段生成,再看admin注册。顺序不能错,否则后台会拿旧字段结构去渲染,自然报错。
5.2 登录接口与用户识别的实战问题
登录接口联调时遇到最多的是Token相关报错。TokenObtainPairView默认返回两个Token:access和refresh,很多前端只用了access,一旦过期就被迫重新登录。我之前提到可以在前端维护一个“刷新Token”的自动续期逻辑。实现思路很简单:用一个fetch拦截器,当请求返回401时,用refresh Token去调刷新接口,成功后重放原请求。注意在并发请求时还要做单次刷新控制,避免多个请求同时刷新导致Token互相覆盖。
另一个跟用户识别相关的坑是HttpResponse里request.user变成了匿名用户AnonymousUser。这通常是因为JWT认证类没有加到视图类上,或者是认证头格式写错了。JWT的header必须是Authorization: Bearer <token>,很多前端误写成Token <token>(那是DRF TokenAuth的格式)。如果发现接口始终拿不到当前用户,先打印一下request.headers里的Authorization字段,八成是格式问题。
5.3 面向后续扩展的代码组织建议
PeekpaUser虽然只是整个项目里的一环,但它的代码组织方式会影响后面所有业务模块。我在项目里用户鉴权相关的文件分别放在users应用下的models.py、views.py、serializers.py、admin.py里,认证接口的路由统一挂在users/urls.py。这样做的好处是业务边界清晰:以后加文章模块、评论模块,各自独立app引用当前用户即可,不用回头改用户模块。
面向第三方登录扩展,我在模型里预留了两个字段:source用于标识注册来源,openid字段可以后续添加,用于存储第三方平台的唯一ID。这种扩展位不需要提前实现全套逻辑,但字段先留着可以避免上线后紧急加字段的迁移烦恼。同样的思路,nickname和avatar这种展示型字段一旦在用户表里,任何模块都能直接引用,而不需要再扯出头像表、昵称表那种没必要的关联。
还有一个开发期间的小建议:给用户模块写单元测试时,建议先测Manager的create_user、create_superuser,再测注册登录接口。测试模型里不用真实文件上传,用SimpleUploadedFile模拟即可覆盖。
结尾:一点实际操作的体会
这套PeekpaUser的设计,我已经在几个项目里来回调整过,最大的体会是:自定义User模型一定要趁早,越晚改越痛苦。字段别贪多,先满足核心业务,宁可后续加字段,也不要一开始堆砌一堆用不到的列。还有一件事,密码哈希和Token过期这种安全细节,千万别想着“以后再补”,上线前就得固化下来。开发过程中我曾有一次把access Token有效期配置得太长,测试时没觉得有问题,后来真上线才发现Token泄漏一小时都无法作废。这类教训都是用一次才能留下深刻印象的。最后再说一个小技巧:自定义User模型时,在__str__方法里把返回值定义成email而不是默认的username,这样所有涉及用户展示的地方(admin后台、错误日志、邮件模板)都会显示清楚易读的标识,排查问题时体验会好很多。希望这篇设计记录能帮你把Django的用户系统搭得顺手。